Skip to content

Commit 7fc86d0

Browse files
jirhikerclaude
andcommitted
fix(chemistry): treat trailing letters in SamplePointID as a sample point
A PointID ending in letters is a sample point, never a base well id: WL-0434A is a sample point on well WL-0434. The ingest treated the whole value as the base, so a workbook naming WL-0434A either failed to resolve a well (the common case, since wells are named WL-0434) or, if such a Thing existed, appended a second letter and produced WL-0434AA. Split the supplied PointID into base and suffix, resolve the well from the base, and compare any supplied letter against the next free incrementor. The computed letter wins because it cannot collide with an existing sample point, but a disagreement is now reported so a human can reconcile it. Lowercase endings are not incrementors, so a well legitimately named "Test Well" is unaffected. Reporting a disagreement needed a non-fatal channel: validation_errors aborts the file, which is too blunt for a letter that is merely unexpected. Add a warnings list to the payload, surfaced by the CLI and counted in the manifest's existing validation_errors_or_warnings field, leaving the exit code at 0. Verified against a real LIMS export (NMT_260503): 104 rows, 98 imported after collapsing Fe/Mn/Sr duplicate methods, loading as WL-0433A and WL-0434A rather than WL-0433AA/WL-0434AA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent beeb4ad commit 7fc86d0

3 files changed

Lines changed: 119 additions & 2 deletions

File tree

cli/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,7 @@ def water_chemistry_bulk_upload(
991991
payload = result.payload if isinstance(result.payload, dict) else {}
992992
summary = payload.get("summary", {})
993993
validation_errors = payload.get("validation_errors", [])
994+
warnings = payload.get("warnings", [])
994995
created_samples = payload.get("created_samples", [])
995996
skipped_duplicates = payload.get("skipped_duplicates", [])
996997

@@ -1046,6 +1047,12 @@ def water_chemistry_bulk_upload(
10461047
)
10471048
typer.echo()
10481049

1050+
if warnings:
1051+
typer.secho("WARNINGS (loaded, but check these)", fg=colors["field"], bold=True)
1052+
for entry in warnings:
1053+
typer.secho(f" - {entry}", fg=colors["field"])
1054+
typer.echo()
1055+
10491056
if validation_errors:
10501057
typer.secho("VALIDATION", fg=colors["accent"], bold=True)
10511058
typer.secho(

services/chemistry_lims.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,23 @@ def keyf(r: dict) -> tuple[str, str, str]:
362362

363363
_SUFFIX_RE_TEMPLATE = r"^{base}([A-Z]+)$"
364364

365+
# A PointID ending in letters is a *sample point* id, never a base well id:
366+
# ``WL-0434A`` is a sample point on well ``WL-0434``. The base must therefore
367+
# end in a non-letter (``WL-0434``, ``MG-030``) for the trailing letters to
368+
# count as an incrementor.
369+
_POINTID_SUFFIX_RE = re.compile(r"^(?P<base>.*[^A-Z])(?P<suffix>[A-Z]+)$")
370+
371+
372+
def split_pointid(pointid: str) -> tuple[str, str | None]:
373+
"""Split a PointID into its base well id and any supplied letter suffix.
374+
375+
``WL-0434A`` -> ``("WL-0434", "A")``; ``WL-0434`` -> ``("WL-0434", None)``.
376+
"""
377+
match = _POINTID_SUFFIX_RE.match(pointid)
378+
if not match:
379+
return pointid, None
380+
return match.group("base"), match.group("suffix")
381+
365382

366383
def _resolve_thing_id(session: Session, pointid: str) -> int | None:
367384
things = session.scalars(select(Thing).where(Thing.name == pointid)).all()
@@ -496,7 +513,22 @@ def bulk_upload_chemistry(
496513

497514
prepped = dedupe_records(prepped)
498515

516+
warnings: list[str] = []
517+
499518
with session_ctx() as session:
519+
# A workbook's SamplePointID may already carry a sample-point letter
520+
# (WL-0434A). The well is always the base (WL-0434), so strip it before
521+
# resolving; the supplied letter is checked against the computed one
522+
# below.
523+
supplied_suffixes: dict[str, str | None] = {}
524+
for rec in prepped:
525+
base, suffix = split_pointid(rec["samplepointid"])
526+
rec["samplepointid"] = base
527+
# Keep the first supplied suffix seen for the well; a workbook
528+
# should not disagree with itself, and if it does the mismatch
529+
# warning below still fires.
530+
supplied_suffixes.setdefault(base, suffix)
531+
500532
# Resolve every distinct (base) sample point to a Thing up front.
501533
base_pointids = sorted({r["samplepointid"] for r in prepped})
502534
thing_ids: dict[str, int | None] = {
@@ -549,7 +581,19 @@ def bucket_key(r: dict) -> tuple[str, str | None]:
549581
max(used_suffixes[thing_id]) + 1 if used_suffixes[thing_id] else 1
550582
)
551583
used_suffixes[thing_id].add(next_int)
552-
sample_point_id = f"{base}{_int_to_suffix(next_int)}"
584+
computed_suffix = _int_to_suffix(next_int)
585+
sample_point_id = f"{base}{computed_suffix}"
586+
587+
# The workbook may have supplied its own letter. The computed one
588+
# wins (it cannot collide with an existing sample point), but a
589+
# disagreement is surfaced so a human can reconcile it.
590+
supplied = supplied_suffixes.get(base)
591+
if supplied is not None and supplied != computed_suffix:
592+
warnings.append(
593+
f"{base}: workbook supplied sample point {base}{supplied}, "
594+
f"but the next free incrementor is {computed_suffix}; "
595+
f"loaded as {sample_point_id}."
596+
)
553597

554598
collection_date = next(
555599
(r["sample_date"] for r in recs if r["sample_date"]), None
@@ -585,6 +629,7 @@ def bucket_key(r: dict) -> tuple[str, str | None]:
585629
validation_errors=validation_errors,
586630
skipped_duplicates=skipped_duplicates,
587631
created=created,
632+
warnings=warnings,
588633
)
589634

590635

@@ -595,8 +640,10 @@ def _result(
595640
validation_errors: list[str],
596641
skipped_duplicates: list[dict],
597642
created: list[dict],
643+
warnings: list[str] | None = None,
598644
) -> ChemistryUploadResult:
599-
rows_with_issues = len(validation_errors) + len(skipped_duplicates)
645+
warnings = warnings or []
646+
rows_with_issues = len(validation_errors) + len(skipped_duplicates) + len(warnings)
600647
payload = {
601648
"summary": {
602649
"total_rows_processed": processed,
@@ -606,12 +653,15 @@ def _result(
606653
"samples_skipped": len(skipped_duplicates),
607654
},
608655
"validation_errors": validation_errors,
656+
"warnings": warnings,
609657
"skipped_duplicates": skipped_duplicates,
610658
"created_samples": created,
611659
}
612660
stderr_parts: list[str] = []
613661
if validation_errors:
614662
stderr_parts.append("\n".join(validation_errors))
663+
if warnings:
664+
stderr_parts.append("\n".join(warnings))
615665
if skipped_duplicates:
616666
dupes = ", ".join(
617667
f"{d['pointid']} (WCLab_ID {d['wclab_id']})" for d in skipped_duplicates

tests/test_chemistry_lims.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
bulk_upload_chemistry,
3434
dedupe_records,
3535
prep_record,
36+
split_pointid,
3637
)
3738

3839
LIMS_HEADER = [
@@ -204,6 +205,65 @@ def test_bulk_upload_skips_duplicate_lab_sample(
204205
assert len(rows_ca) == 1 # not duplicated
205206

206207

208+
@pytest.mark.parametrize(
209+
"pointid,expected",
210+
[
211+
("WL-0434", ("WL-0434", None)),
212+
("WL-0434A", ("WL-0434", "A")),
213+
("WL-0434AB", ("WL-0434", "AB")),
214+
("MG-030", ("MG-030", None)),
215+
("MG-030A", ("MG-030", "A")),
216+
# Lowercase is not an incrementor, so a name ending in one is a base.
217+
("Test Well", ("Test Well", None)),
218+
("Test WellA", ("Test Well", "A")),
219+
],
220+
)
221+
def test_split_pointid(pointid, expected):
222+
"""A PointID ending in capitals is a sample point; the well is the base."""
223+
assert split_pointid(pointid) == expected
224+
225+
226+
def test_bulk_upload_strips_supplied_suffix_to_find_the_well(
227+
tmp_path, water_well_thing, _cleanup_chemistry
228+
):
229+
"""A workbook naming sample point 'Test WellA' resolves to well 'Test Well'."""
230+
_write_workbook(
231+
tmp_path / "lims.xlsx",
232+
[_lims_row("calcium", "12.5", pointid="Test WellA", SampleNumber="LAB-1")],
233+
)
234+
235+
result = bulk_upload_chemistry(tmp_path / "lims.xlsx")
236+
237+
assert result.exit_code == 0, result.stderr
238+
# Not 'Test WellAA' -- the supplied letter is not doubled.
239+
assert result.payload["created_samples"][0]["sample_point_id"] == "Test WellA"
240+
assert result.payload["warnings"] == []
241+
242+
243+
def test_bulk_upload_warns_when_supplied_suffix_disagrees(
244+
tmp_path, water_well_thing, _cleanup_chemistry
245+
):
246+
"""Computed letter wins; the disagreement is reported but does not fail."""
247+
_write_workbook(
248+
tmp_path / "first.xlsx",
249+
[_lims_row("calcium", "12.5", pointid="Test WellA", SampleNumber="LAB-1")],
250+
)
251+
bulk_upload_chemistry(tmp_path / "first.xlsx")
252+
253+
# A second lab sample still labelled 'A', though 'B' is the next free one.
254+
_write_workbook(
255+
tmp_path / "second.xlsx",
256+
[_lims_row("calcium", "9.9", pointid="Test WellA", SampleNumber="LAB-2")],
257+
)
258+
result = bulk_upload_chemistry(tmp_path / "second.xlsx")
259+
260+
assert result.exit_code == 0, result.stderr
261+
assert result.payload["created_samples"][0]["sample_point_id"] == "Test WellB"
262+
warnings = result.payload["warnings"]
263+
assert len(warnings) == 1
264+
assert "Test WellA" in warnings[0] and "Test WellB" in warnings[0]
265+
266+
207267
def test_bulk_upload_appends_new_lab_sample_with_next_suffix(
208268
tmp_path, water_well_thing, _cleanup_chemistry
209269
):

0 commit comments

Comments
 (0)