Skip to content

Commit cf3b6d6

Browse files
jmborrclaude
andauthored
Bank to det renaming (#82)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fc3ab9f commit cf3b6d6

6 files changed

Lines changed: 182 additions & 40 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,17 @@ The main reduction model is in `src/usansred/reduce.py`.
141141
`USANS_<run>_monitor_scan_ARN.txt` and
142142
`USANS_<run>_detector_scan_ARN_peak_<bank>.txt`.
143143
- `CombinedSample` combines raw scan data from multiple samples before reduction.
144-
- `XYData`, `IQData`, and `MonitorData` live in `src/usansred/model.py`.
144+
- `XYData`, `IQData`, and `MonitorData` live in `src/usansred/models/data.py`.
145145

146146
Output names currently include:
147147

148148
- `UN_<sample>_det_1_unscaled.txt`
149149
- `UN_<sample>_det_1.txt`
150-
- `UN_<sample>_det_1_lb.txt`
150+
- `UN_<sample>_det_<n>_unscaled.txt` and `UN_<sample>_det_<n>.txt` for harmonics
151+
`n = 2..4`, only when `save_all_harmonics` is enabled. These are written flat in
152+
the output directory, not in per-bank subdirectories.
151153
- `UN_<sample>_det_1_background_subtracted.txt` (only when a background or
152-
empty cell was subtracted)
154+
empty cell was subtracted; first harmonic only)
153155
- `summary.xlsx`
154156

155157
## Coding Standards

docs/source/api/usansred.rst

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,18 @@ usansred.io.save
2424
:undoc-members:
2525
:show-inheritance:
2626

27-
usansred.model
28-
--------------
27+
usansred.models.data
28+
--------------------
29+
30+
.. automodule:: usansred.models.data
31+
:members:
32+
:undoc-members:
33+
:show-inheritance:
34+
35+
usansred.models.reduction\_config
36+
---------------------------------
2937

30-
.. automodule:: usansred.model
38+
.. automodule:: usansred.models.reduction_config
3139
:members:
3240
:undoc-members:
3341
:show-inheritance:

docs/source/user/reduce.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,18 @@ Once reduction is finished, subdirectory ``result/`` is created containing the f
247247
+ ``UN_X5D2_8_det_1_background_subtracted.txt`` (**_background_subtracted.txt**)
248248
is the data after background (or empty-cell) subtraction.
249249
It is only written when a background or empty cell was actually subtracted.
250+
251+
The ``_det_1`` infix denotes the first harmonic (first detector bank), the only one written
252+
by default. When ``save_all_harmonics`` is enabled in the JSON setup file, the unscaled and
253+
scaled files are written for every harmonic, using the same names with the harmonic number
254+
in place of the ``1``:
255+
256+
.. code-block:: text
257+
258+
UN_X5D2_8_det_1_unscaled.txt UN_X5D2_8_det_1.txt
259+
UN_X5D2_8_det_2_unscaled.txt UN_X5D2_8_det_2.txt
260+
UN_X5D2_8_det_3_unscaled.txt UN_X5D2_8_det_3.txt
261+
UN_X5D2_8_det_4_unscaled.txt UN_X5D2_8_det_4.txt
262+
263+
Background subtraction is currently applied to the first harmonic only, so
264+
``_det_1_background_subtracted.txt`` has no counterpart for the higher harmonics.

src/usansred/reduce.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -347,49 +347,72 @@ def dump_reduced_data_to_csv(
347347
"""Write this measurement's reduced data to CSV text files in the experiment's output directory.
348348
349349
Each flag enables one category of output file (all default to True; the reduction
350-
workflow in ``Experiment.dump_reduced_data`` always uses the defaults). A category
351-
is also skipped when its corresponding data is empty.
350+
workflow in ``Experiment.dump_reduced_data`` always uses the defaults). Higher
351+
harmonics with no data are skipped with a warning, but the first harmonic is
352+
required for the ``detector_data`` and ``scaled_data`` categories.
352353
353354
Parameters
354355
----------
355356
detector_data : bool
356357
Write the stitched, monitor-normalized data, ``UN_<name>_det_1_unscaled.txt``.
357-
With ``save_all_harmonics``, higher banks go to ``bank_<n>/UN_<name>_unscaled.txt``.
358-
Skipped when no detector data is present.
358+
With ``save_all_harmonics``, higher harmonics go to ``UN_<name>_det_<n>_unscaled.txt``.
359359
scaled_data : bool
360360
Write the data rescaled by analyzer solid angle, sample thickness, and transmission,
361-
``UN_<name>_det_1.txt``. With ``save_all_harmonics``, higher banks go to
362-
``bank_<n>/UN_<name>.txt``.
361+
``UN_<name>_det_1.txt``. With ``save_all_harmonics``, higher harmonics go to
362+
``UN_<name>_det_<n>.txt``.
363363
background_subtracted_data : bool
364364
Write the background- (or empty-cell-) subtracted data,
365365
``UN_<name>_det_1_background_subtracted.txt``. Only written when a subtraction
366366
actually occurred (``is_reduced`` is True).
367+
368+
Raises
369+
------
370+
RuntimeError
371+
If ``detector_data`` or ``scaled_data`` is requested but the first harmonic of
372+
the corresponding data is missing or empty.
367373
"""
368-
if detector_data and self.data:
369-
filepath = os.path.join(self.experiment.output_dir, f"UN_{self.name}_det_1_unscaled.txt")
370-
self.dump_data_to_csv(filepath, self.data)
371-
if self.config.save_all_harmonics:
372-
for i in range(1, self.num_of_banks):
373-
bank = i + 1 # start with the second order
374-
filepath = os.path.join(
375-
self.experiment.output_dir,
376-
f"bank_{bank}",
377-
f"UN_{self.name}_unscaled.txt",
378-
)
379-
self.dump_data_to_csv(filepath, self.detector_data[i])
374+
# Harmonic (detector bank) n is written to ``_det_<n>``; only the first harmonic is
375+
# written unless ``save_all_harmonics`` is set.
376+
num_of_harmonics = self.num_of_banks if self.config.save_all_harmonics else 1
377+
378+
def has_data(data: IQData) -> bool:
379+
return any((data.q, data.i, data.e, data.t))
380+
381+
if detector_data and (not self.detector_data or not has_data(self.detector_data[0])):
382+
raise RuntimeError(f"Cannot write detector data for {self.label}: first harmonic data is missing.")
383+
384+
if scaled_data and (not self.data_scaled or not has_data(self.data_scaled[0])):
385+
raise RuntimeError(f"Cannot write scaled data for {self.label}: first harmonic data is missing.")
386+
387+
if detector_data:
388+
missing_harmonics = []
389+
for harmonic in range(1, min(num_of_harmonics, len(self.detector_data)) + 1):
390+
if not has_data(self.detector_data[harmonic - 1]):
391+
missing_harmonics.append(harmonic)
392+
continue
393+
filepath = os.path.join(self.experiment.output_dir, f"UN_{self.name}_det_{harmonic}_unscaled.txt")
394+
self.dump_data_to_csv(filepath, self.detector_data[harmonic - 1])
395+
missing_harmonics.extend(range(len(self.detector_data) + 1, num_of_harmonics + 1))
396+
if missing_harmonics:
397+
logger.warning(
398+
f"No detector data is available for {self.label} for harmonics {missing_harmonics}; "
399+
"skipping those data dumps."
400+
)
380401

381402
if scaled_data:
382-
filepath = os.path.join(self.experiment.output_dir, f"UN_{self.name}_det_1.txt")
383-
self.dump_data_to_csv(filepath, self.data_scaled[0])
384-
if self.config.save_all_harmonics:
385-
for i in range(1, self.num_of_banks):
386-
bank = i + 1 # start with the second order
387-
filepath = os.path.join(
388-
self.experiment.output_dir,
389-
f"bank_{bank}",
390-
f"UN_{self.name}.txt",
391-
)
392-
self.dump_data_to_csv(filepath, self.data_scaled[i])
403+
missing_harmonics = []
404+
for harmonic in range(1, min(num_of_harmonics, len(self.data_scaled)) + 1):
405+
if not has_data(self.data_scaled[harmonic - 1]):
406+
missing_harmonics.append(harmonic)
407+
continue
408+
filepath = os.path.join(self.experiment.output_dir, f"UN_{self.name}_det_{harmonic}.txt")
409+
self.dump_data_to_csv(filepath, self.data_scaled[harmonic - 1])
410+
missing_harmonics.extend(range(len(self.data_scaled) + 1, num_of_harmonics + 1))
411+
if missing_harmonics:
412+
logger.warning(
413+
f"No scaled data is available for {self.label} for harmonics {missing_harmonics}; "
414+
"skipping those data dumps."
415+
)
393416

394417
if background_subtracted_data:
395418
# Only written when a background or empty cell was actually subtracted

tests/integration/test_reduce.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,19 +152,21 @@ def test_main_save_all_harmonics(mock_parse_args, data_server, tmp_path):
152152

153153
reduce()
154154

155-
for bank in range(2, 5):
156-
bank_dir = output_dir / f"bank_{bank}"
157-
assert bank_dir.is_dir()
155+
# Higher harmonics are written flat in the output directory as ``_det_<n>``, alongside
156+
# the first-harmonic ``_det_1`` files; no per-bank subdirectories are created.
157+
for harmonic in range(2, 5):
158158
for name in ["EmptyPCell", "S115_dry", "S115_pc3"]:
159-
harmonic_file = bank_dir / f"UN_{name}_unscaled.txt"
159+
harmonic_file = output_dir / f"UN_{name}_det_{harmonic}_unscaled.txt"
160160
assert harmonic_file.is_file()
161161
assert harmonic_file.stat().st_size > 0
162162
assert len(harmonic_file.read_text(encoding="utf-8").splitlines()) == 65
163163

164-
scaled_file = bank_dir / f"UN_{name}.txt"
164+
scaled_file = output_dir / f"UN_{name}_det_{harmonic}.txt"
165165
assert scaled_file.is_file()
166166
assert scaled_file.stat().st_size > 0
167167

168+
assert not list(output_dir.glob("bank_*")), "per-bank subdirectories should no longer be created"
169+
168170
assert_reduction_log_files(
169171
output_dir, [("S115_pc3", "sample"), ("S115_dry", "sample"), ("EmptyPCell", "background")]
170172
)

tests/unit/test_sample.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,98 @@ def test_dump_uneven_lists(self, mock_experiment):
455455
os.unlink(filepath)
456456

457457

458+
class TestDumpAllHarmonics:
459+
"""Tests for the ``save_all_harmonics`` output files of Sample.dump_reduced_data_to_csv."""
460+
461+
@staticmethod
462+
def _make_two_bank_sample(experiment: Experiment, save_all_harmonics: bool, tmp_path) -> Sample:
463+
experiment.output_dir = str(tmp_path)
464+
experiment._config.save_all_harmonics = save_all_harmonics
465+
sample = _make_sample(experiment, "test", [])
466+
# One (Q,I,E) curve per detector bank, both for the unscaled and the rescaled data
467+
sample.detector_data = [
468+
IQData(q=[0.1, 0.2], i=[100.0, 200.0], e=[10.0, 14.0]),
469+
IQData(q=[0.3, 0.4], i=[300.0, 400.0], e=[17.0, 20.0]),
470+
]
471+
sample.data_scaled = [
472+
IQData(q=[0.1, 0.2], i=[1.0, 2.0], e=[0.1, 0.2]),
473+
IQData(q=[0.3, 0.4], i=[3.0, 4.0], e=[0.3, 0.4]),
474+
]
475+
return sample
476+
477+
def test_writes_one_file_per_harmonic(self, mock_experiment_2banks, tmp_path):
478+
"""With save_all_harmonics, each bank is written flat as ``_det_<n>``."""
479+
sample = self._make_two_bank_sample(mock_experiment_2banks, True, tmp_path)
480+
481+
sample.dump_reduced_data_to_csv(background_subtracted_data=False)
482+
483+
for harmonic in (1, 2):
484+
assert (tmp_path / f"UN_test_det_{harmonic}_unscaled.txt").is_file()
485+
assert (tmp_path / f"UN_test_det_{harmonic}.txt").is_file()
486+
assert not list(tmp_path.glob("bank_*")), "no per-bank subdirectories should be created"
487+
488+
def test_higher_harmonics_hold_their_own_bank_data(self, mock_experiment_2banks, tmp_path):
489+
"""The ``_det_2`` files hold bank 2's curve, not a copy of bank 1's."""
490+
sample = self._make_two_bank_sample(mock_experiment_2banks, True, tmp_path)
491+
492+
sample.dump_reduced_data_to_csv(background_subtracted_data=False)
493+
494+
rows = list(csv.reader((tmp_path / "UN_test_det_2_unscaled.txt").read_text().splitlines()))
495+
assert [float(row[0]) for row in rows] == [0.3, 0.4]
496+
assert [float(row[1]) for row in rows] == [300.0, 400.0]
497+
498+
def test_only_first_harmonic_without_the_flag(self, mock_experiment_2banks, tmp_path):
499+
"""Without save_all_harmonics, only the first harmonic is written."""
500+
sample = self._make_two_bank_sample(mock_experiment_2banks, False, tmp_path)
501+
502+
sample.dump_reduced_data_to_csv(background_subtracted_data=False)
503+
504+
assert (tmp_path / "UN_test_det_1_unscaled.txt").is_file()
505+
assert (tmp_path / "UN_test_det_1.txt").is_file()
506+
assert not (tmp_path / "UN_test_det_2_unscaled.txt").exists()
507+
assert not (tmp_path / "UN_test_det_2.txt").exists()
508+
509+
def test_missing_first_detector_harmonic_aborts_output(self, mock_experiment_2banks, tmp_path):
510+
"""Missing first-harmonic detector data must abort output generation."""
511+
sample = self._make_two_bank_sample(mock_experiment_2banks, True, tmp_path)
512+
sample.detector_data = []
513+
514+
with pytest.raises(RuntimeError, match="first harmonic data is missing"):
515+
sample.dump_reduced_data_to_csv(scaled_data=False, background_subtracted_data=False)
516+
517+
def test_missing_first_scaled_harmonic_aborts_output(self, mock_experiment_2banks, tmp_path):
518+
"""Missing first-harmonic scaled data must abort output generation."""
519+
sample = self._make_two_bank_sample(mock_experiment_2banks, True, tmp_path)
520+
sample.data_scaled = []
521+
522+
with pytest.raises(RuntimeError, match="first harmonic data is missing"):
523+
sample.dump_reduced_data_to_csv(detector_data=False, background_subtracted_data=False)
524+
525+
def test_missing_higher_harmonics_warns_and_skips_files(self, mock_experiment_2banks, tmp_path, caplog):
526+
"""Missing higher harmonics should be warned about and skipped."""
527+
sample = self._make_two_bank_sample(mock_experiment_2banks, True, tmp_path)
528+
sample.detector_data = sample.detector_data[:1]
529+
sample.data_scaled = sample.data_scaled[:1]
530+
531+
with caplog.at_level(logging.WARNING):
532+
sample.dump_reduced_data_to_csv(background_subtracted_data=False)
533+
534+
assert (tmp_path / "UN_test_det_1_unscaled.txt").is_file()
535+
assert (tmp_path / "UN_test_det_1.txt").is_file()
536+
assert not (tmp_path / "UN_test_det_2_unscaled.txt").exists()
537+
assert not (tmp_path / "UN_test_det_2.txt").exists()
538+
assert "No detector data is available" in caplog.text
539+
assert "No scaled data is available" in caplog.text
540+
541+
def test_empty_first_scaled_harmonic_aborts_output(self, mock_experiment_2banks, tmp_path):
542+
"""An empty first-harmonic scaled curve must abort output generation."""
543+
sample = self._make_two_bank_sample(mock_experiment_2banks, True, tmp_path)
544+
sample.data_scaled[0] = IQData()
545+
546+
with pytest.raises(RuntimeError, match="first harmonic data is missing"):
547+
sample.dump_reduced_data_to_csv(detector_data=False, background_subtracted_data=False)
548+
549+
458550
class TestDumpBackgroundSubtracted:
459551
"""Tests for dumping the background-subtracted data file."""
460552

0 commit comments

Comments
 (0)