Repository navigation
Refuse TubeCodes listed twice in the sample accession or controls files - #370
lucaspatel wants to merge 11 commits into
Conversation
- validate_plate_df no longer treats undescribed samples as controls - astype(str) -> map(str) so missing values still render as 'nan' - make_replicates accepts all-empty text columns - text placeholders take the str dtype - replace deprecated set_index(verify_integrity=True)
A plate TubeCode matching two sample accession rows grew the left merge in _merge_accession_to_compressed_plate_df, and the index-aligned update() then shifted every later sample name onto the wrong well. With the same TubeCode under two different sample names, validate_plate_df passed and 357 of 384 wells were mislabelled. The same merge in add_controls duplicated a well when a blank or katharoseq tube was listed twice. Both merges now raise, naming each ambiguous TubeCode and its values. Duplicates not on the plate are still ignored.
…ndas 3 Write missing section values as empty cells, reject a blank Sample_ID explicitly, and report blank sample names in read counts. The trimmed/raw split moves from three notebooks into split_trimmed_read_counts.
There was a problem hiding this comment.
🟡 Changes recommended
Duplicate missing TubeCodes still bypass validation and can corrupt sample assignments or duplicate wells.
1 open issue
What changed in this PR
Prevents ambiguous TubeCodes from corrupting plate assignments. The diff also includes Python 3.11/pandas 3 migration work related to #368.
Changes:
- Adds duplicate TubeCode checks before accession and controls merges.
- Updates pandas compatibility and missing-value handling.
- Adds regression tests and updates notebooks and test infrastructure.
| File | Description |
|---|---|
| setup.py | Requires Python 3.11 and pandas 3. |
| notebooks/tellseq_D_variable_volume_pooling.ipynb | Uses shared read-count splitting. |
| notebooks/metatranscriptomics_matrix_pipeline_seqcount_norm.ipynb | Updates string conversion and read-count splitting. |
| notebooks/matrix_tube_pipeline_seqcount_norm.ipynb | Uses shared read-count splitting. |
| notebooks/amplicon_pre_prep_file_generator.ipynb | Explicitly checks index uniqueness. |
| metapool/util.py | Adds dtype-aware placeholders and unique indexing. |
| metapool/tests/test_util.py | Tests unique indexing. |
| metapool/tests/test_sample_sheet.py | Tests missing-value handling. |
| metapool/tests/test_plate.py | Covers empty replication columns and index compatibility. |
| metapool/tests/test_metapool.py | Tests duplicate TubeCodes and read-count validation. |
| metapool/scripts/tests/test_seqpro.py | Updates temporary directories and path assertions. |
| metapool/scripts/tests/test_seqpro_mf.py | Updates temporary directories and path assertions. |
| metapool/sample_sheet.py | Improves missing-value serialization and validation. |
| metapool/prep.py | Uses shared unique indexing. |
| metapool/plate.py | Handles replication column types. |
| metapool/metapool.py | Adds merge guards and pandas compatibility changes. |
| metapool/count.py | Updates indexing and string conversions. |
| metapool/abs_quant_sample_info.py | Updates lot-ID string conversion. |
| environment.yml | Targets Python 3.11 and pandas 3. |
| conftest.py | Uses non-interactive plotting for tests. |
| .github/workflows/deploy.yml | Targets Python 3.11. |
| .github/workflows/ci.yml | Targets Python 3.11. |
Review effort: Balanced
| on_plate = lookup_df[TUBECODE_KEY].isin(plate_df[TUBECODE_KEY]) | ||
| dup_mask = lookup_df[TUBECODE_KEY].duplicated(keep=False) | ||
| dups = lookup_df[on_plate & dup_mask & lookup_df[TUBECODE_KEY].notna()] | ||
| if dups.empty: | ||
| return | ||
|
|
||
| value_cols = [c for c in dups.columns if c != TUBECODE_KEY] | ||
| details = [] | ||
| for tubecode, rows in dups.groupby(TUBECODE_KEY, sort=True): |
AmandaBirmingham
left a comment
There was a problem hiding this comment.
Below is from Claude. Note from Amanda: I threw out about half Claude's findings or proposed fixes as not worth it; these are the ones that seemed either trivial to fix or worth the (probably still pretty minimal) Claude test-writing effort.
Review: PR #370 (duplicate TubeCode guard), plus follow-ups from #368
Nothing below is blocking. Part A covers this PR. Part B covers code that came in with #368, which is already merged, so those items are follow-ups rather than changes to this PR. Worth noting that copilot also flags A2.
Line numbers refer to the PR merged onto current master.
Part A: this PR (#370)
A1. The compress-plates error blames "the sample accession file" for duplicates that file doesn't contain
metapool/metapool.py:2170-2172
extend_sample_accession_df (metapool/util.py:136-138) strips the qiita_id. prefix and merges on sample_name. If two studies share a sample name, each accession row with that name is duplicated, once per project. All three main compression notebooks pass that extended frame into compress_plates, and the guard then reports a duplicate "in the sample accession file", which has none:
metadata adds 14577.41B.Month6.1 alongside 12986.41B.Month6.1
extended rows: 3942 vs file rows: 3941 ; dup TubeCodes in file: 0
The following TubeCodes appear more than once in the sample accession file:
363132553 (41B.Month6.1 / Celeste_Adaptation_12986, 41B.Month6.1 / Celeste_Marmoset_14577)
Catching this is still an improvement: before the guard, the wells shifted silently.
Suggested fix: change the source wording to "the sample accession data" (two test regexes pin the current text).
A2. Missing TubeCodes are excluded from the check, but the merge still duplicates them
metapool/metapool.py:2210
The guard drops missing TubeCodes (& lookup_df[TUBECODE_KEY].notna()), but a pandas left merge matches a missing key to a missing key (confirmed under pandas 3.0.6). If the plate has an empty TubeCode cell and the accession file has two blank-TubeCode rows, that well gets two rows. The index-aligned update() then shifts later sample names onto the wrong wells, with no error. The same applies in add_controls. This predates the PR, but the guard explicitly lets it through, and no test covers it either way (removing .notna() still passes all tests).
Suggested fix: either reject blank TubeCodes in the lookup data or drop them before the merge, and add a test for whichever is chosen.
A3. No test checks that the add_controls guard runs after leading zeroes are stripped
metapool/metapool.py:2311-2316
Moving the guard above strip_tubecode_leading_zeroes still passes all tests, because the new test uses a plate that is already stripped. With a plate that still has leading zeroes, that order would find no matches, so 15 real duplicates would pass silently and the merge would add 15 rows.
Suggested fix: add a test that uses comp_plate_exp_fp_w_leading_zeroes_tubecode with the doubled blanks directory and preserve_leading_zeroes=False, expecting the ValueError.
A4. The new compress tests only cover exact duplicates, which the notebooks already reject on load
metapool/tests/test_metapool.py:235, :251
The notebooks load accession files through join_dfs_from_files, which already rejects an exact repeated TubeCode such as 0363132553 twice. In those flows the guard only adds protection for collisions that appear after normalization (e.g. 0363132553 and 363132553), and for the cross-study case in A1. Neither has a test.
Suggested fix: add a test for the leading-zero collision:
sa_df + {'other', '363132553'} (file already has '0363132553')
-> raise: ... 363132553 (41B.Month6.1, other)
control, preserve_leading_zeroes=True -> no raise
A one-line comment at the guard saying it covers duplicates that only appear after normalization would also explain why two TubeCode checks exist.
A5. The controls error message is noisy
metapool/metapool.py:2217
controls_df concatenates the blanks and katharoseq frames, so every blank row has missing Kathseq_RackID and number_of_cells. With the same blanks scan saved twice, the message is 1,156 characters long, made of 15 entries like:
363183400 (negative_control / nan / nan, negative_control / nan / nan)
Suggested fix: skip missing values when joining the details (e.g. " / ".join(str(v) for v in row if pd.notna(v))).
A6. Nit: the order of a multi-TubeCode error message isn't pinned
The only multi-TubeCode test asserts a regex that ends at ": ", so changing sort=True to sort=False in the groupby still passes. This only matters if the order is meant to be part of the message's contract.
Part B: from #368 (already merged; follow-ups)
B1. Missing-sample-name errors report index labels as "row(s)"
metapool/metapool.py:1500-1508
The message says "at row(s): N", but N comes from counts_df.index labels. A blank on file line 3 is reported as "row(s): 1". On a filtered or concatenated frame, the label may not match any position. The Sample_ID check added in the same PR (metapool/sample_sheet.py:1062-1067) counts sample rows from 1, so users see two numbering schemes.
Suggested fix: Keep the labels and word it "index label(s)". Update the assertions at test_metapool.py:1639 and :1658 to match.
B2. The PM_SAMPLE_KEY entry in make_replicates isn't tested
metapool/plate.py:969-976; test at metapool/tests/test_plate.py:935-945
The new test's subtests cover orig_name, Library Well and replicate, but not PM_SAMPLE_KEY. Removing it from the tuple still passes all tests, yet it is needed:
int Sample, real code -> OK, Sample dtype str
int Sample, PM_SAMPLE_KEY dropped -> TypeError Invalid value '1000.A1' for dtype 'int64'
The loop also converts whole columns to str, including rows that aren't replicated. That's low impact, since read_plate_map_csv already reads Sample as str.
Suggested fix: add a PM_SAMPLE_KEY case with integer names. The comment could also say it covers any non-text column, not only all-empty ones.
B3. No test covers whitespace-only sample names in the missing-name check
metapool/metapool.py:1504
Reducing the check to missing = names.isna() still passes all tests. That case can happen: read_csv(..., dtype=str) keeps " " as ' ' rather than NaN.
Suggested fix: add a test with a whitespace-only name.
B4. split_trimmed_read_counts negates str.contains without converting to text
metapool/metapool.py:1520
On an object column that holds a non-string value, str.contains returns NaN for that row and ~trimmed_mask raises:
object col ['a.trimmed.fq', 'b.fq', 123] -> TypeError bad operand type for unary ~: 'float'
all-int col [1, 2] -> AttributeError Can only use .str accessor with string values
The notebook read paths produce strings, so this is low severity and not a regression.
Suggested fix: read_counts_df[sample_column].astype(str).str.contains('trimmed'). This is safe because missing names are already rejected earlier. na=False alone isn't a good fix: it would silently classify a non-string name as raw.
B5. The amplicon notebook repeats set_unique_index inline
notebooks/amplicon_pre_prep_file_generator.ipynb
The notebook does set_index, checks is_unique and builds the duplicate message itself, copying the "Index has duplicate keys" text from metapool/util.py:234. If the helper's logic or message changes, the notebook won't follow.
Suggested fix: add from metapool.util import set_unique_index (it isn't in scope via from metapool.metapool import *) and replace the inline code with qiita_df = set_unique_index(qiita_df, 'sample_name').
B6. Nit: test_metapool.py imports both io and StringIO
metapool/tests/test_metapool.py:2, :7
io. is used once (io.StringIO in test_split_trimmed_read_counts_err_missing_sample_name); the module already imports StringIO directly.
Suggested fix: change that call to StringIO(...) and drop import io.
Review follow-ups on the duplicate-TubeCode guard: - Drop missing/blank TubeCodes from the lookup data before both the guard and the TubeCode merge, in compress_plates and add_controls. A blank TubeCode cannot identify a well, but a pandas left merge matches NaN to NaN, so a blank plate cell paired with every blank lookup row, duplicating the well and shifting later sample names via the index-aligned update(). - Name the compress source 'the sample accession data', not 'file': cross-study extension can duplicate a TubeCode the file itself holds once. - Skip missing values in the duplicate message so a concatenated blanks and katharoseq frame no longer reads 'nan / nan' for every entry. - Tests: the drop helper, a blank-TubeCode well that no longer duplicates, the post-strip leading-zero collision in compress, and that the add_controls guard runs after leading zeroes are stripped.
|
Thanks for the careful pass (and for pre-filtering Claude's list). Part A is addressed in Part A
On A2 (the decision): we drop blank-TubeCode rows from the lookup data before the merge rather than reject them. A blank TubeCode can't identify a well, so those rows had no legitimate way to join on TubeCode — the only thing they did was the bug, where a pandas left merge matched blank-to-blank and duplicated the well (shifting later names via the aligned |
The runner image no longer ships a preinstalled Miniconda, so setup-miniconda failed with 'No installed conda base environment found'. Pin miniforge-version latest (and bump the action to v3) so it installs one; Miniforge also defaults to conda-forge, off the anaconda defaults channel.
CI solves environment.yml fresh and pulled pandas 3.1.0, whose to_csv formats some values differently from the 3.0.x the notebook expected-output files were generated with, failing the tellseq_B exact-match tests. Cap at <3.1 so CI matches the validated version; keep environment.yml and setup.py in sync.
b6a0710 to
2ecb829
Compare
…odes # Conflicts: # environment.yml # setup.py

Hi Amanda — this PR fixes a bug in plate compression that can put sample names on the wrong wells without any error. I found it while going through the amplicon notebook step by step for the Qiita-MIINT library prep work that follows #369.
When a TubeCode on the plate appears in more than one row of the sample accession file, the left merge in
_merge_accession_to_compressed_plate_dfadds a row. Theupdate()that follows aligns rows by position, so from that well onward every sample name shifts down by one. I reproduced this on the existing test fixtures, with the KatharoSeq tubes removed to match a typical amplicon plate:validate_plate_dfpasses, and 357 of 384 wells end up with the wrong name.add_controlshas the same problem. If a blank or KatharoSeq tube is listed twice, for example a blanks scan saved twice in the blanks directory, its well appears twice in the output.Both merges now raise a
ValueErrorbefore merging. The error names each ambiguous TubeCode and the values it maps to, e.g.:A duplicate TubeCode that isn't on the plate is still ignored, since it can't affect the merge. That keeps large multi-study accession files with an unrelated duplicate working as before.
There are four new tests in
test_metapool.py, all using existing fixtures:The first three fail on master. On Python 3.10 / pandas 2.3 the compress, add_controls and validate tests pass and flake8 is clean. The full suite has no failures beyond 7 that also fail on a clean master checkout here (seqpro and
test_count_collector). This also merges cleanly with #368, andtest_metapool.pypasses on the merged tree under pandas 3.Since the notebooks call
compress_platesandadd_controls, they need no changes. The fix covers the amplicon, metagenomics compression and TellSeq notebooks.Best,
Lucas