Skip to content

fix: Cut OpenLab CDS peak memory 4x and handle optional ACAML metadata - #1254

Merged
nathan-stender merged 1 commit into
mainfrom
fix/openlab-cds-memory-and-optional-metadata
Aug 25, 2026
Merged

fix: Cut OpenLab CDS peak memory 4x and handle optional ACAML metadata#1254
nathan-stender merged 1 commit into
mainfrom
fix/openlab-cds-memory-and-optional-metadata

Conversation

@nathan-stender

Copy link
Copy Markdown
Collaborator

Motivation

A 745 MB Agilent OpenLab CDS .rslt bundle (10 injections) could not be converted at all. Two independent problems:

  1. Memory. The read path held the entire file in memory twice, and the nested-zip access pattern thrashed.
  2. Parsability. Four ACAML elements that are absent from some exports were accessed unconditionally.

Memory

  • Added NamedFileContents.get_seekable_bytes_stream(). The existing get_bytes_stream() does self.contents.read() and wraps the result in a BytesIO, holding the file twice. The new method returns the underlying handle when it's already a seekable binary stream (as it is for allotrope_from_file), falling back to the old behaviour otherwise. Safe because to_allotrope.py and discover_vendor() both seek(0) before handing the handle to a parser, and the new method seeks to 0 itself. Only the OpenLab CDS parser opts in; the ~30 other callers of get_bytes_stream() are untouched.
  • decode_data now spools each .dx member to a NamedTemporaryFile in 1 MB chunks before opening it as a nested zip. Previously it opened a ZipFile over a ZipExtFile, which isn't seekable, so zipfile emulated every backward seek by re-reading and discarding in 16 MB chunks — and the .CH members sit at byte ~74.1 M of a 74.5 MB member.

Measured with an RSS sampler on the 745 MB bundle: peak RSS for the decode stage ~1.00 GB → ~235 MB; full end-to-end conversion peaks at ~218 MB. Decoding is also faster (1.7 s → 1.3 s) now that the seek emulation is gone.

Optional ACAML metadata

Each of these previously raised on the affected file:

Location Problem Fix
decode_acaml_data Resources/SeparationMedium absent → KeyError .get(), plus new get_column_info() in the structure layer, which resolves to {} so the column fields are omitted
decode_data no .sqx in the bundle → StopIteration .sqx is now optional; the analysis method falls back to the ACAML Content/Method names via new get_acaml_analysis_method(), used only when they agree on a single name
extract_rx_file Result/InjectionCompound absent → KeyError hoisted to result_data.get("InjectionCompound"); peaks are emitted without compound metadata
create_metadata single-token instrument name → .split()[1] IndexError new get_brand_name(), returns the second token only when present

Also guarded the unconditional peak["Area"]["@unit"], peak["Height"]["@unit"] and peak["Peak Metadata"] reads in create_peak, whose sibling expressions already treated those keys as optional.

The bundle now converts to 30 measurements (10 injections × 2 chromatograms + 1 pressure trace) in 2.0 s.

Test data

New fixture Sirius-2023-09-01 07-52-44-04-00.rslt, 33 KB, containing no customer data. Cut down from the existing anonymised Luxo HPLC fixture: 2 injections instead of 10, and each .dx keeps only the three members the parser reads (2 .CH + the pressure .IT), dropping ~1.1 MB of unread instrument traces per injection. Then edited to reproduce all four conditions above.

Confirmed to be real regression coverage: with the source changes stashed, the new fixture fails (KeyError: 'SeparationMedium') while the existing fixture still passes.

The pre-existing Luxo HPLC expected JSON is byte-identical — no changes to existing test data.

Out of scope

  • 3D UV data cubes. The .UV member is 95.5% of each .dx and is not read, so the output has no 3D spectra. ASM does define three-dimensional ultraviolet spectrum data cube (AFR_0002551) in the LC schema and generated model, but it isn't exposed by the schema mapper and no parser emits it. Deliberately deferred.
  • Calibration sample role. constants.SAMPLE_ROLE_TYPE maps only Blank and Sample, so calibration standards get no sample role type. Adding it would change output for existing files containing calibration samples, so it's left for a separate change.
  • Remaining memory scaling. Retained data grows ~4.8 MB per injection because decode_data_cubes converts numpy float64 arrays into Python float lists (~4× blowup). Peak is now dominated by retained data rather than file I/O. Keeping the cubes as numpy arrays until serialization is the next lever.

Testing

  • hatch run lint — clean (ruff, black, mypy over 788 files)
  • hatch run test_all.py3.10:pytest tests/1488 passed

🤖 Generated with Claude Code

Large .rslt bundles (745MB, 10 injections) could not be converted: the read
path held the whole file in memory twice, and four ACAML elements that are
absent from some exports raised on access.

Memory:
- Add NamedFileContents.get_seekable_bytes_stream(), which returns the
  underlying handle when it is already a seekable binary stream instead of
  copying the file into a BytesIO. to_allotrope and discover_vendor both
  seek(0) before handing the handle to a parser, so sharing it is safe.
- Spool each .dx member to a temp file before opening it as a nested zip.
  Opening a ZipFile over a non-seekable ZipExtFile made zipfile emulate every
  backward seek by re-reading in 16MB chunks, and the .CH members sit at the
  very end of a 74MB member.

Peak RSS for the decode stage drops from ~1.00GB to ~235MB, and decoding is
also faster now that the seek emulation is gone.

Optional metadata, each of which previously raised:
- Resources/SeparationMedium absent -> omit the column fields.
- No .sqx sequence file -> read the analysis method from the ACAML method
  documents, when they agree on a single name.
- Result/InjectionCompound absent -> emit peaks without compound metadata.
- Single-token instrument name -> omit brand name rather than IndexError.

Also guard the unconditional Area/Height/"Peak Metadata" reads in create_peak,
whose sibling expressions already treated those keys as optional.

Add a 33KB test fixture covering all four conditions, cut down from the
existing Luxo HPLC fixture: 2 injections, and only the three .dx members the
parser actually reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nathan-stender
nathan-stender requested review from a team and slopez-b as code owners August 24, 2026 20:44
@nathan-stender
nathan-stender merged commit 7b2937f into main Aug 25, 2026
9 checks passed
@nathan-stender
nathan-stender deleted the fix/openlab-cds-memory-and-optional-metadata branch August 25, 2026 15:45
nathan-stender added a commit that referenced this pull request Aug 25, 2026
## Problem

The OpenLab CDS help docs tell users they may compress their `.rslt`
result set before dropping it in the File Watched directory:

> (Optional) Users can additionally compress their RSLT file to a zipped
file within their File Watched directory if they choose. The Agilent
OpenLabs CDS connector is able to process this format along with the
standard RSLT file export.

That was never true in allotropy. `SUPPORTED_EXTENSIONS = "rslt"` meant
a `.zip` upload was rejected before the parser saw any bytes:

```
EXPLICIT .zip FAIL: AllotropeConversionError Unsupported file extension 'zip' for parser 'Agilent OpenLab CDS', expected one of '['rslt']'.
DISCOVER .zip FAIL: AllotropeVendorNotFoundError No vendor could be identified for file with extension '.zip'.
```

The `.rslt` files we already accept *are* zip archives, so the contents
were never the problem — only the extension gate and, for one shape, an
extra layer of nesting.

## Changes

- **Accept the `zip` extension** in addition to `rslt`.
- **Replace the unconditional `sniff()` with a content check.** `return
True` was only safe while `.rslt` was exclusive to this parser. With
`zip` added, the old sniff would have claimed every `.zip` and stolen
files from Cytiva Unicorn (`"zip"`) and AppBio Absolute Q (`"csv,zip"`).
It now looks for an ACAML member.
- **Unwrap extra layers of compression** (`open_result_set`).
Compressing the `.rslt` *folder* yields an archive whose members are the
result set; compressing the `.rslt` *file* yields an archive whose only
member is another archive. Both now work.
- **Clearer errors.** A non-result-set archive raised bare
`StopIteration`, and an uncompressed file raised `BadZipFile`. Both now
raise `AllotropeConversionError` with a message, since malformed
archives are newly reachable input.

## Verification

Tested against archives produced by the real macOS Finder "Compress"
path (`ditto -c -k --sequesterRsrc --keepParent`, which adds the
`__MACOSX` resource forks that hand-built zips lack):

```
finder_folder.zip           parse=IDENTICAL  discover=AGILENT_OPENLAB_CDS
finder_folder_renamed.rslt  parse=IDENTICAL  discover=AGILENT_OPENLAB_CDS
finder_file.zip             parse=IDENTICAL  discover=AGILENT_OPENLAB_CDS
finder_file_renamed.rslt    parse=IDENTICAL  discover=AGILENT_OPENLAB_CDS
```

IDENTICAL = byte-equal ASM against the existing fixture (~192KB of
output, data cubes included), ignoring `file name` / `UNC path`, which
legitimately differ.

- Full suite: **1496 passed**, `hatch run lint` clean.
- `tests/discover_vendor_test.py` passes, confirming no cross-vendor
sniff regressions.

## Notes for reviewers

- The new test **synthesizes** each compression shape from the existing
`.rslt` fixture rather than checking in new files, avoiding ~3MB of
duplicate test data.
- Mutation-tested the new test (`MAX_ARCHIVE_NESTING` 3 → 1): exactly
the two nested cases fail, so the assertions aren't vacuous.
- Nesting is bounded at 3 archive levels. Beyond that a user would have
to compress three or more times, which the docs don't describe.
- **Still open, outside this repo:** if the connector zips the watched
folder itself, a pre-zipped upload may not reach allotropy to benefit
from this. The `Unsupported file extension 'zip'` error above is the
signal that it did.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Update: rebased on main (2026-08-25)

Merged latest `main`, which now contains #1254 (OpenLab CDS memory +
optional ACAML metadata) and #1258. Resolving the overlap in
`agilent_openlab_cds_decoder.py` changed two things in this PR:

- **`.sqx` stays optional.** This PR originally routed the sequence-file
lookup through a new `_get_first_matching_filename` helper that raised
when `.sqx` was absent. #1254 made `.sqx` optional (falling back to the
ACAML method documents), and the `Sirius` fixture it added has no
`.sqx`, so main's behaviour wins. The helper became dead code and was
removed — the "clearer errors" bullet above now applies only to the
ACAML and non-archive cases, both still covered by tests.
- **`sniff` no longer copies the whole file into memory.** It now calls
`get_seekable_bytes_stream()` instead of `get_bytes_stream()`. Result
sets reach ~1GB, and `get_bytes_stream()` buffers the entire upload —
using it in `sniff` would have silently undone #1254's memory fix on
every discovery pass.

Also fixed the `Quality Checks` failure that predated the merge:
`from_file` returns `Mapping[str, Any]`, not `dict[str, Any]`, so the
`expected` fixture's annotation failed mypy.

Re-verified after the merge: **1498 passed** (up from 1496 — main added
the two `Sirius` fixture cases), `hatch run lint` clean,
`tests/discover_vendor_test.py` green.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
nathan-stender added a commit that referenced this pull request Aug 25, 2026
#1257)

## Motivation

Sample files attached to parser issues are usually **real customer
exports**, but nothing in `CLAUDE.md`, `CONTRIBUTING.md`, or the
parser-generator skill said so, or said what to do about it. This adds a
`## Test Data` section covering the two requirements and the traps that
make each easy to get wrong.

Prompted by #1254, where a 745 MB customer bundle had to be reduced to a
33 KB fixture and the rules for doing so were entirely ad-hoc.

## What's documented

**1. Anonymize every customer-unique identifier** —
instrument/site/project names, operators and clients, serials and part
numbers, barcodes, sample and method names, absolute paths (which embed
usernames), GUIDs. Two non-obvious points:

- Some identifiers are **load-bearing**: the parser matches on them, so
renaming one side of a reference silently breaks the fixture. Concrete
cases: ACAML `TraceID` GUIDs must equal the `.dx` member filenames, and
signal names like `DAD1A,Sig=280,4 Ref=off` are parsed for wavelength
and bandwidth.
- **Shape affects control flow**, not just readability — `Kaisla` and
`Luxo HPLC` take different paths, because `brand_name` reads the second
whitespace token.

**2. Trim to a representative sample** — KBs, not MBs. Drop container
members the parser never opens (an OpenLab CDS `.dx` is ~75 MB but the
71 MB `.UV` member is never read), cut repeated records, downsample long
series. Two gotchas:

- **Keep list-shaped XML at 2+ elements** — `xmltodict` maps a single
repeated element to a dict rather than a list, so trimming a section to
one entry breaks any code that indexes it.
- **Prefer deriving from an existing anonymized fixture** when the new
case is a metadata variation rather than a new binary layout.
Reintroduces zero customer data and is usually smaller.

**3. Verify and generate** — stash the fix and confirm the fixture
actually fails before trusting it as regression coverage. Also writes
down two rules that previously lived only in
`.claude/skills/parser-generator/skill.md` or nowhere at all: generate
expected JSON with `--overwrite` (the framework mocks UUIDs, so
hand-generated JSON can never match), and never edit existing expected
JSON to make a change pass.

## Scope

Docs only — no code, no test, no behavior change. `CONTRIBUTING.md` is
left alone; it targets external contributors and would want a different
framing, so that can be a follow-up if wanted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
nathan-stender pushed a commit that referenced this pull request Aug 25, 2026
### Fixed

- Agilent OpenLab CDS - accept zipped result sets (#1252)
- Tecan Magellan - support compact Magellan Pro 7.5 exports (#1258)
- Cut OpenLab CDS peak memory 4x and handle optional ACAML metadata
(#1254)
- Report custom information keys in a stable order (#1249)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants