From ce36a0ff217e018a5240d9b4a6e7956e8275e35c Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Thu, 4 Jun 2026 16:45:20 -0700 Subject: [PATCH 01/12] update for deploy only on version tags; --- .github/workflows/docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 958b87e..9f7353e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,7 +4,6 @@ on: push: tags: - 'v*.*.*' - - 'test*' workflow_dispatch: permissions: From 1d22c1891f06e7e829fefac93e27419b5654e833 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Thu, 4 Jun 2026 18:03:39 -0700 Subject: [PATCH 02/12] default in the argparse was still mhap_id but we changed the default to be seq (it's changed to seq in the function itself) --- src/pmotools/scripts/pmo_to_tables/extract_allele_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pmotools/scripts/pmo_to_tables/extract_allele_table.py b/src/pmotools/scripts/pmo_to_tables/extract_allele_table.py index bfd458b..93f8144 100755 --- a/src/pmotools/scripts/pmo_to_tables/extract_allele_table.py +++ b/src/pmotools/scripts/pmo_to_tables/extract_allele_table.py @@ -91,7 +91,7 @@ def get_parser() -> argparse.ArgumentParser: "--default_base_col_names", type=str, required=False, - default="library_sample_name,target_name,mhap_id", + default="library_sample_name,target_name,seq", help="default base column names, must be length 3", ) return parser From 374e14ee980deee4c0f9b5efe47e6ed729c95d81 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 10:38:47 -0700 Subject: [PATCH 03/12] small fixes for mutation of input data for filter_pmo_by_target_ids; --- src/pmotools/pmo_engine/pmo_processor.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/pmotools/pmo_engine/pmo_processor.py b/src/pmotools/pmo_engine/pmo_processor.py index cef95fb..651600a 100644 --- a/src/pmotools/pmo_engine/pmo_processor.py +++ b/src/pmotools/pmo_engine/pmo_processor.py @@ -646,8 +646,6 @@ def filter_pmo_by_library_sample_ids(pmodata, library_sample_ids: set[int]): if "targeted_genomes" in pmodata: pmo_out["targeted_genomes"] = copy.deepcopy(pmodata["targeted_genomes"]) - if "read_counts_by_stage" in pmodata: - pmo_out["read_counts_by_stage"] = [] # need to update read_counts_by_stage, library_sample_info, specimen_info, detected_microhaplotypes # specimen_info @@ -703,6 +701,7 @@ def filter_pmo_by_library_sample_ids(pmodata, library_sample_ids: set[int]): pmo_out["detected_microhaplotypes"].append(new_detected_microhaplotypes) # read_counts_by_stage if "read_counts_by_stage" in pmodata: + pmo_out["read_counts_by_stage"] = [] for read_count in pmodata["read_counts_by_stage"]: new_read_count = { "read_counts_by_library_sample_by_stage": [], @@ -725,6 +724,7 @@ def filter_pmo_by_library_sample_ids(pmodata, library_sample_ids: set[int]): ]["library_sample_id"] = library_id_index_key[ sample["library_sample_id"] ] + pmo_out["read_counts_by_stage"].append(new_read_count) return pmo_out @staticmethod @@ -884,11 +884,12 @@ def filter_pmo_by_target_ids(pmodata, target_ids: set[int]): pmo_out["representative_microhaplotypes"]["targets"] ) # update new target_id index - microhap_info["target_id"] = target_info_index_key[ + microhap_info_copy = copy.deepcopy(microhap_info) + microhap_info_copy["target_id"] = target_info_index_key[ microhap_info["target_id"] ] pmo_out["representative_microhaplotypes"]["targets"].append( - copy.deepcopy(microhap_info) + microhap_info_copy ) # representative_microhaplotypes pmo_out["detected_microhaplotypes"] = [] @@ -908,10 +909,11 @@ def filter_pmo_by_target_ids(pmodata, target_ids: set[int]): for target in sample["target_results"]: if target["mhaps_target_id"] in mhaps_target_id_new_key: # update with new mhaps_target_id id - target["mhaps_target_id"] = mhaps_target_id_new_key[ + target_copy = copy.deepcopy(target) + target_copy["mhaps_target_id"] = mhaps_target_id_new_key[ target["mhaps_target_id"] ] - new_sample["target_results"].append(copy.deepcopy(target)) + new_sample["target_results"].append(target_copy) new_detected_microhaplotypes["library_samples"].append(new_sample) pmo_out["detected_microhaplotypes"].append(new_detected_microhaplotypes) @@ -938,11 +940,12 @@ def filter_pmo_by_target_ids(pmodata, target_ids: set[int]): for target in sample["read_counts_for_targets"]: if target["target_id"] in target_ids: # update with new target_id index - target["target_id"] = target_info_index_key[ + target_copy = copy.deepcopy(target) + target_copy["target_id"] = target_info_index_key[ target["target_id"] ] new_samples["read_counts_for_targets"].append( - copy.deepcopy(target) + target_copy ) new_read_counts_by_bioid[ "read_counts_by_library_sample_by_stage" From 6ad954b3d6e5c6436f662f8f03f06829d9e41cd1 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 11:08:18 -0700 Subject: [PATCH 04/12] update the hashes because now reads_by_stage is now properly exported so the old hashes were wrong; --- tests/test_pmo_engine/test_pmo_processor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_pmo_engine/test_pmo_processor.py b/tests/test_pmo_engine/test_pmo_processor.py index 2c4a41b..bce394b 100755 --- a/tests/test_pmo_engine/test_pmo_processor.py +++ b/tests/test_pmo_engine/test_pmo_processor.py @@ -358,7 +358,7 @@ def test_filter_pmo_by_library_sample_ids(self): ) with open(output_fnp, "w") as f: json.dump(pmo_data_select_targets, f) - self.assertEqual("4a2c7b9d84322cc43adf2b9f0c518244", md5sum_of_fnp(output_fnp)) + self.assertEqual("36d5cb8a79962707374fae86ba1a6f90", md5sum_of_fnp(output_fnp)) # check pmo extracted against PMO schema checker = PMOChecker(self.pmo_jsonschema_data) checker.validate_pmo_json(pmo_data_select_targets) @@ -374,7 +374,7 @@ def test_filter_pmo_by_library_sample_names(self): ) with open(output_fnp, "w") as f: json.dump(pmo_data_select_library_sample_names, f) - self.assertEqual("ffdaeb6b57ed9a5c51e56cfaa4621796", md5sum_of_fnp(output_fnp)) + self.assertEqual("6bd4e6367a62f1eb3c73f481a5ae9013", md5sum_of_fnp(output_fnp)) # check pmo extracted against PMO schema checker = PMOChecker(self.pmo_jsonschema_data) checker.validate_pmo_json(pmo_data_select_library_sample_names) @@ -388,7 +388,7 @@ def test_filter_pmo_by_specimen_ids(self): ) with open(output_fnp, "w") as f: json.dump(pmo_data_select_targets, f) - self.assertEqual("f3dd2e0aee9afa82336ff48475a3c69c", md5sum_of_fnp(output_fnp)) + self.assertEqual("664de0f14180de852f3f5d8942454004", md5sum_of_fnp(output_fnp)) # check pmo extracted against PMO schema checker = PMOChecker(self.pmo_jsonschema_data) checker.validate_pmo_json(pmo_data_select_targets) @@ -402,7 +402,7 @@ def test_filter_pmo_by_specimen_names(self): ) with open(output_fnp, "w") as f: json.dump(pmo_data_select_targets, f) - self.assertEqual("f3dd2e0aee9afa82336ff48475a3c69c", md5sum_of_fnp(output_fnp)) + self.assertEqual("664de0f14180de852f3f5d8942454004", md5sum_of_fnp(output_fnp)) # check pmo extracted against PMO schema checker = PMOChecker(self.pmo_jsonschema_data) checker.validate_pmo_json(pmo_data_select_targets) @@ -420,7 +420,7 @@ def test_extract_from_pmo_samples_with_meta_groupings(self): with open(output_fnp, "w") as f: json.dump(pmo_data_select_meta, f) - self.assertEqual("f0f67e399885824a7aa318ead8b3d09c", md5sum_of_fnp(output_fnp)) + self.assertEqual("9deb0bccc0017593697190d45ae6c0a4", md5sum_of_fnp(output_fnp)) # check pmo extracted against PMO schema checker = PMOChecker(self.pmo_jsonschema_data) checker.validate_pmo_json(pmo_data_select_meta) From 5ea5a2e149b6404d4e4e86aac4e4d91368f11a95 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 12:44:32 -0700 Subject: [PATCH 05/12] fix for when exporting a header for bed locations didn't add a newline; when asking for a sorted output would only return first panel, now will return all panels; added test --- src/pmotools/pmo_engine/pmo_exporter.py | 14 +++---- tests/test_pmo_engine/test_pmo_exporter.py | 48 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/pmotools/pmo_engine/pmo_exporter.py b/src/pmotools/pmo_engine/pmo_exporter.py index 361ebd7..646436a 100644 --- a/src/pmotools/pmo_engine/pmo_exporter.py +++ b/src/pmotools/pmo_engine/pmo_exporter.py @@ -451,6 +451,7 @@ def write_bed_locs(bed_locs: list[BedLoc], fnp, add_header: bool = False): ] ) ) + f.write("\n") for bed_loc in bed_locs: f.write( "\t".join( @@ -540,11 +541,10 @@ def extract_panels_insert_bed_loc( :param sort_output: whether to sort output by genomic location :return: a list of target inserts, with named tuples with fields: chrom, start, end, name, score, strand, ref_seq, extra_info """ - bed_loc_out = {} + bed_loc_out = [] if select_panel_ids is None: select_panel_ids = list(range(len(pmodata["panel_info"]))) for panel_id in select_panel_ids: - bed_loc_out_per_panel = [] for reaction_id in range(len(pmodata["panel_info"][panel_id]["reactions"])): for target_id in pmodata["panel_info"][panel_id]["reactions"][ reaction_id @@ -589,7 +589,7 @@ def extract_panels_insert_bed_loc( if "ref_seq" not in tar["insert_location"] else tar["insert_location"]["ref_seq"] ) - bed_loc_out_per_panel.append( + bed_loc_out.append( BedLoc( tar["insert_location"]["chrom"], tar["insert_location"]["start"], @@ -602,12 +602,8 @@ def extract_panels_insert_bed_loc( extra_info, ) ) - if sort_output: - return sorted( - bed_loc_out_per_panel, - key=lambda bed: (bed.chrom, bed.start, bed.end), - ) - bed_loc_out[panel_id] = bed_loc_out_per_panel + if sort_output: + return sorted(bed_loc_out, key=lambda bed: (bed.chrom, bed.start, bed.end)) return bed_loc_out @staticmethod diff --git a/tests/test_pmo_engine/test_pmo_exporter.py b/tests/test_pmo_engine/test_pmo_exporter.py index 1984bfa..99c7045 100755 --- a/tests/test_pmo_engine/test_pmo_exporter.py +++ b/tests/test_pmo_engine/test_pmo_exporter.py @@ -105,6 +105,54 @@ def test_extract_panels_insert_bed_loc(self): PMOExporter.write_bed_locs(all_target_inserts, output_fnp) self.assertEqual("52b1f79a3a89f8265573fa54b5a7ce57", md5sum_of_fnp(output_fnp)) + def test_extract_panels_insert_bed_loc_covers_all_reactions(self): + # regression: previously the function returned early inside the reaction + # loop, so only the first reaction of the first panel was returned. + import copy + + pmo = copy.deepcopy(self.combined_pmo_data) + panel = pmo["panel_info"][0] + targets = panel["reactions"][0]["panel_targets"] + half = len(targets) // 2 + panel["reactions"] = [ + {"reaction_name": "pool1", "panel_targets": targets[:half]}, + {"reaction_name": "pool2", "panel_targets": targets[half:]}, + ] + bed_locs = PMOExporter.extract_panels_insert_bed_loc(pmo, sort_output=False) + # every target across BOTH reactions is present + self.assertEqual(len(bed_locs), len(targets)) + self.assertEqual( + {b.name for b in bed_locs}, + {pmo["target_info"][t]["target_name"] for t in targets}, + ) + + def test_write_bed_locs_header_on_own_line(self): + # regression: header was written without a trailing newline, gluing the + # first data row onto it. + bed_locs = PMOExporter.extract_targets_insert_bed_loc( + self.combined_pmo_data, sort_output=True + ) + out_fnp = os.path.join(self.test_dir.name, "with_header.bed") + PMOExporter.write_bed_locs(bed_locs, out_fnp, add_header=True) + with open(out_fnp) as f: + lines = f.read().splitlines() + self.assertEqual( + lines[0], + "\t".join( + [ + "#chrom", + "start", + "end", + "name", + "score", + "strand", + "ref_seq", + "extra_info", + ] + ), + ) + self.assertEqual(len(lines), len(bed_locs) + 1) + def test_extract_alleles_per_sample_table(self): allele_data = PMOExporter.extract_alleles_per_sample_table( self.combined_pmo_data, From c71bbfde77612dbebd53e1cec6d17c7d9d99649e Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 13:28:27 -0700 Subject: [PATCH 06/12] update so pseudocigar is properly constructed during the building functions; updated unittests; --- src/pmotools/pmo_builder/mhap_table_to_pmo.py | 99 ++++++++++++++++++- .../test_mhap_table_to_pmo.py | 71 +++++++------ .../test_schema_validation_integration.py | 8 ++ 3 files changed, 140 insertions(+), 38 deletions(-) diff --git a/src/pmotools/pmo_builder/mhap_table_to_pmo.py b/src/pmotools/pmo_builder/mhap_table_to_pmo.py index be0ae34..70e4a57 100644 --- a/src/pmotools/pmo_builder/mhap_table_to_pmo.py +++ b/src/pmotools/pmo_builder/mhap_table_to_pmo.py @@ -26,6 +26,13 @@ def mhap_table_to_pmo( masking_delim: str = ",", microhaplotype_name_col: str | None = None, pseudocigar_col: str | None = None, + pseudocigar_chrom_col: str | None = None, + pseudocigar_start_col: str | None = None, + pseudocigar_end_col: str | None = None, + pseudocigar_ref_seq_col: str | None = None, + pseudocigar_strand_col: str | None = None, + pseudocigar_genome_id: int | None = None, + pseudocigar_generation_description_col: str | None = None, quality_col: str | None = None, additional_representative_mhap_cols: list | None = None, additional_mhap_detected_cols: list | None = None, @@ -73,6 +80,20 @@ def mhap_table_to_pmo( :type microhaplotype_name_col: str, optional :param pseudocigar_col: the name of the column containing a pseudocigar for the microhaplotype :type pseudocigar_col: str, optional + :param pseudocigar_chrom_col: column for the pseudocigar ref_loc chromosome; defaults to chrom_col + :type pseudocigar_chrom_col: str, optional + :param pseudocigar_start_col: column for the pseudocigar ref_loc start (required if pseudocigar_col is set) + :type pseudocigar_start_col: str, optional + :param pseudocigar_end_col: column for the pseudocigar ref_loc end (required if pseudocigar_col is set) + :type pseudocigar_end_col: str, optional + :param pseudocigar_ref_seq_col: optional column for the pseudocigar ref_loc reference sequence + :type pseudocigar_ref_seq_col: str, optional + :param pseudocigar_strand_col: optional column for the pseudocigar ref_loc strand + :type pseudocigar_strand_col: str, optional + :param pseudocigar_genome_id: genome id for the pseudocigar ref_loc; defaults to genome_id + :type pseudocigar_genome_id: int, optional + :param pseudocigar_generation_description_col: optional column describing how the pseudocigar was generated + :type pseudocigar_generation_description_col: str, optional :param quality_col: the name of the column containing the ANSI FASTQ per-base quality score for this sequence :type quality_col: str, optional :param additional_representative_mhap_cols: additional columns to add to the representative microhaplotypes table @@ -100,6 +121,13 @@ def mhap_table_to_pmo( masking_delim=masking_delim, microhaplotype_name_col=microhaplotype_name_col, pseudocigar_col=pseudocigar_col, + pseudocigar_chrom_col=pseudocigar_chrom_col, + pseudocigar_start_col=pseudocigar_start_col, + pseudocigar_end_col=pseudocigar_end_col, + pseudocigar_ref_seq_col=pseudocigar_ref_seq_col, + pseudocigar_strand_col=pseudocigar_strand_col, + pseudocigar_genome_id=pseudocigar_genome_id, + pseudocigar_generation_description_col=pseudocigar_generation_description_col, quality_col=quality_col, additional_representative_mhap_cols=additional_representative_mhap_cols, ) @@ -160,6 +188,13 @@ def create_representative_microhaplotype_dict( masking_delim: str = ",", microhaplotype_name_col: str | None = None, pseudocigar_col: str | None = None, + pseudocigar_chrom_col: str | None = None, + pseudocigar_start_col: str | None = None, + pseudocigar_end_col: str | None = None, + pseudocigar_ref_seq_col: str | None = None, + pseudocigar_strand_col: str | None = None, + pseudocigar_genome_id: int | None = None, + pseudocigar_generation_description_col: str | None = None, quality_col: str | None = None, additional_representative_mhap_cols: list[str] | None = None, ): @@ -198,6 +233,20 @@ def create_representative_microhaplotype_dict( :type microhaplotype_name_col: str, optional :param pseudocigar_col: the name of the column containing a pseudocigar for the microhaplotype :type pseudocigar_col: str, optional + :param pseudocigar_chrom_col: column for the pseudocigar ref_loc chromosome; defaults to chrom_col + :type pseudocigar_chrom_col: str, optional + :param pseudocigar_start_col: column for the pseudocigar ref_loc start (required if pseudocigar_col is set) + :type pseudocigar_start_col: str, optional + :param pseudocigar_end_col: column for the pseudocigar ref_loc end (required if pseudocigar_col is set) + :type pseudocigar_end_col: str, optional + :param pseudocigar_ref_seq_col: optional column for the pseudocigar ref_loc reference sequence + :type pseudocigar_ref_seq_col: str, optional + :param pseudocigar_strand_col: optional column for the pseudocigar ref_loc strand + :type pseudocigar_strand_col: str, optional + :param pseudocigar_genome_id: genome id for the pseudocigar ref_loc; defaults to genome_id + :type pseudocigar_genome_id: int, optional + :param pseudocigar_generation_description_col: optional column describing how the pseudocigar was generated + :type pseudocigar_generation_description_col: str, optional :param quality_col: the name of the column containing the ANSI FASTQ per-base quality score for this sequence :type quality_col: str, optional :param additional_representative_mhap_cols: additional columns to add to the representative microhaplotypes table @@ -211,6 +260,21 @@ def create_representative_microhaplotype_dict( microhaplotype_table, additional_representative_mhap_cols ) + # the pseudocigar ref_loc shares the chromosome / genome with the + # microhaplotype location by default, but each may use its own column + ps_chrom_col = pseudocigar_chrom_col if pseudocigar_chrom_col else chrom_col + ps_genome_id = ( + pseudocigar_genome_id if pseudocigar_genome_id is not None else genome_id + ) + if pseudocigar_col and not ( + ps_chrom_col and pseudocigar_start_col and pseudocigar_end_col + ): + raise ValueError( + "pseudocigar_col is set, so a Pseudocigar ref_loc must be " + "constructable: set a chromosome (pseudocigar_chrom_col or " + "chrom_col), pseudocigar_start_col, and pseudocigar_end_col." + ) + def get_if_present(row, col): return row[col] if col and pd.notna(row[col]) else None @@ -263,6 +327,12 @@ def warn_if_duplicated_seqs(df, target_col, seq_col): alt_annotations_col, microhaplotype_name_col, pseudocigar_col, + ps_chrom_col, + pseudocigar_start_col, + pseudocigar_end_col, + pseudocigar_ref_seq_col, + pseudocigar_strand_col, + pseudocigar_generation_description_col, quality_col, ] masking_cols = [ @@ -319,7 +389,34 @@ def warn_if_duplicated_seqs(df, target_col, seq_col): if val := get_if_present(row, microhaplotype_name_col): mhap["microhaplotype_name"] = val if val := get_if_present(row, pseudocigar_col): - mhap["pseudo_cigar"] = val + if not ( + pd.notna(row[ps_chrom_col]) + and pd.notna(row[pseudocigar_start_col]) + and pd.notna(row[pseudocigar_end_col]) + ): + raise ValueError( + f"pseudocigar present for target {target}, seq " + f"{row[seq_col]} but its ref_loc chrom/start/end " + "is missing" + ) + ref_loc = { + "genome_id": ps_genome_id, + "chrom": row[ps_chrom_col], + "start": row[pseudocigar_start_col], + "end": row[pseudocigar_end_col], + } + if pseudocigar_ref_seq_col and pd.notna(row[pseudocigar_ref_seq_col]): + ref_loc["ref_seq"] = row[pseudocigar_ref_seq_col] + if pseudocigar_strand_col and pd.notna(row[pseudocigar_strand_col]): + ref_loc["strand"] = row[pseudocigar_strand_col] + pseudocigar = {"pseudocigar_seq": val, "ref_loc": ref_loc} + if pseudocigar_generation_description_col and pd.notna( + row[pseudocigar_generation_description_col] + ): + pseudocigar["pseudocigar_generation_description"] = row[ + pseudocigar_generation_description_col + ] + mhap["pseudocigar"] = pseudocigar if val := get_if_present(row, quality_col): mhap["quality"] = val if additional_representative_mhap_cols: diff --git a/tests/test_pmo_builder/test_mhap_table_to_pmo.py b/tests/test_pmo_builder/test_mhap_table_to_pmo.py index fad711b..b50903c 100644 --- a/tests/test_pmo_builder/test_mhap_table_to_pmo.py +++ b/tests/test_pmo_builder/test_mhap_table_to_pmo.py @@ -510,52 +510,49 @@ def test_create_representative_microhaplotype_dict_with_ad_cols(self): mhap_table_ad_cols = self.small_mhap_table.copy() mhap_table_ad_cols["adcol1"] = "this" mhap_table_ad_cols["adcol2"] = "that" - mhap_table_ad_cols["cig"] = "cigs" rep_dict_with_ad_cols = copy.deepcopy(self.small_representative_dict) - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][0]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][0]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][0][ - "pseudo_cigar" - ] = "cigs" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][1]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][1]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][1][ - "pseudo_cigar" - ] = "cigs" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][2]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][2]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][0]["microhaplotypes"][2][ - "pseudo_cigar" - ] = "cigs" - rep_dict_with_ad_cols["targets"][1]["microhaplotypes"][0]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][1]["microhaplotypes"][0]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][1]["microhaplotypes"][0][ - "pseudo_cigar" - ] = "cigs" - rep_dict_with_ad_cols["targets"][1]["microhaplotypes"][1]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][1]["microhaplotypes"][1]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][1]["microhaplotypes"][1][ - "pseudo_cigar" - ] = "cigs" - rep_dict_with_ad_cols["targets"][2]["microhaplotypes"][0]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][2]["microhaplotypes"][0]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][2]["microhaplotypes"][0][ - "pseudo_cigar" - ] = "cigs" - rep_dict_with_ad_cols["targets"][2]["microhaplotypes"][1]["adcol1"] = "this" - rep_dict_with_ad_cols["targets"][2]["microhaplotypes"][1]["adcol2"] = "that" - rep_dict_with_ad_cols["targets"][2]["microhaplotypes"][1][ - "pseudo_cigar" - ] = "cigs" + for target in rep_dict_with_ad_cols["targets"]: + for mhap in target["microhaplotypes"]: + mhap["adcol1"] = "this" + mhap["adcol2"] = "that" actual = create_representative_microhaplotype_dict( mhap_table_ad_cols, additional_representative_mhap_cols=["adcol1", "adcol2"], - pseudocigar_col="cig", ) self.assertEqual(actual, rep_dict_with_ad_cols) + def test_create_representative_microhaplotype_dict_with_pseudocigar(self): + tbl = self.small_mhap_table.copy() + tbl["chrom"] = "chr1" + tbl["start"] = 100 + tbl["end"] = 150 + tbl["cig"] = "8M" + actual = create_representative_microhaplotype_dict( + tbl, + chrom_col="chrom", + start_col="start", + end_col="end", + pseudocigar_col="cig", + pseudocigar_start_col="start", + pseudocigar_end_col="end", + ) + first = actual["targets"][0]["microhaplotypes"][0] + # pseudocigar is a Pseudocigar object with a GenomicLocation ref_loc; + # the chromosome is reused from chrom_col and genome_id defaults to 0 + self.assertEqual(first["pseudocigar"]["pseudocigar_seq"], "8M") + self.assertEqual( + first["pseudocigar"]["ref_loc"], + {"genome_id": 0, "chrom": "chr1", "start": 100, "end": 150}, + ) + + def test_pseudocigar_without_ref_loc_columns_raises(self): + tbl = self.small_mhap_table.copy() + tbl["cig"] = "8M" + with self.assertRaises(ValueError): + create_representative_microhaplotype_dict(tbl, pseudocigar_col="cig") + @patch( "pmotools.pmo_builder.mhap_table_to_pmo.create_representative_microhaplotype_dict" ) diff --git a/tests/test_pmo_builder/test_schema_validation_integration.py b/tests/test_pmo_builder/test_schema_validation_integration.py index 766bca5..5f88b9b 100644 --- a/tests/test_pmo_builder/test_schema_validation_integration.py +++ b/tests/test_pmo_builder/test_schema_validation_integration.py @@ -191,6 +191,8 @@ def test_full_1_0_0_toy_pmo_validates_against_schema(): umis_col="umis", microhaplotype_name_col="microhap_name", pseudocigar_col="pseudocigar", + pseudocigar_start_col="start", + pseudocigar_end_col="end", quality_col="quality", masking_seq_start_col="mask_start", masking_seq_segment_size_col="mask_segment", @@ -467,6 +469,9 @@ def test_slimer_1_1_0_toy_pmo_validates_against_schema(): "umis": [10], "microhap_name": ["mh1"], "pseudocigar": ["8M"], + "pseudocigar_chrom": ["chr1"], + "pseudocigar_start": [120], + "pseudocigar_end": [127], "quality": ["ABCD"], "mask_start": ["1"], "mask_segment": ["2"], @@ -480,6 +485,9 @@ def test_slimer_1_1_0_toy_pmo_validates_against_schema(): umis_col="umis", microhaplotype_name_col="microhap_name", pseudocigar_col="pseudocigar", + pseudocigar_chrom_col="pseudocigar_chrom", + pseudocigar_start_col="pseudocigar_start", + pseudocigar_end_col="pseudocigar_end", quality_col="quality", masking_seq_start_col="mask_start", masking_seq_segment_size_col="mask_segment", From aa1c22f63b06f86473ab09cf47f249fb69915685 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 13:38:15 -0700 Subject: [PATCH 07/12] start and end were flipped when setting location for forward primer location; --- .../pmo_builder/panel_information_to_pmo.py | 4 ++-- .../test_panel_information_to_pmo.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pmotools/pmo_builder/panel_information_to_pmo.py b/src/pmotools/pmo_builder/panel_information_to_pmo.py index 949fd8c..563307f 100644 --- a/src/pmotools/pmo_builder/panel_information_to_pmo.py +++ b/src/pmotools/pmo_builder/panel_information_to_pmo.py @@ -332,8 +332,8 @@ def build_target_info_dict( fwd_primer_dict["location"] = { "genome_id": genome_id, "chrom": row[chrom_col], - "end": int(row[forward_primers_start_col]), - "start": int(row[forward_primers_end_col]), + "start": int(row[forward_primers_start_col]), + "end": int(row[forward_primers_end_col]), } if strand_col and pd.notna(row[strand_col]): fwd_primer_dict["location"]["strand"] = row[strand_col] diff --git a/tests/test_pmo_builder/test_panel_information_to_pmo.py b/tests/test_pmo_builder/test_panel_information_to_pmo.py index 29158a3..bf74cf3 100644 --- a/tests/test_pmo_builder/test_panel_information_to_pmo.py +++ b/tests/test_pmo_builder/test_panel_information_to_pmo.py @@ -416,8 +416,8 @@ def test_build_target_info_dict_full_info( "location": { "genome_id": 0, "chrom": "chrom1", - "end": 1, - "start": 2, + "start": 1, + "end": 2, }, }, "reverse_primer": { @@ -443,8 +443,8 @@ def test_build_target_info_dict_full_info( "location": { "genome_id": 0, "chrom": "chrom1", - "end": 1, - "start": 2, + "start": 1, + "end": 2, }, }, "reverse_primer": { @@ -470,8 +470,8 @@ def test_build_target_info_dict_full_info( "location": { "genome_id": 0, "chrom": "chrom1", - "end": 1, - "start": 2, + "start": 1, + "end": 2, }, }, "reverse_primer": { @@ -526,8 +526,8 @@ def test_build_target_info_dict_missing_info( "location": { "genome_id": 0, "chrom": "chrom1", - "end": 1, - "start": 2, + "start": 1, + "end": 2, }, }, "reverse_primer": {"seq": "GTT"}, From fb800996dcd852f5d8004ef6f7a0b2b066f6eb24 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 14:53:31 -0700 Subject: [PATCH 08/12] fix for the change in the schema to qpcr name in the schema which was never updated in the pmtools-python; --- src/pmotools/pmo_builder/metatable_to_pmo.py | 12 +++--- .../test_pmo_builder/test_metatable_to_pmo.py | 37 ++++++++++++++----- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/pmotools/pmo_builder/metatable_to_pmo.py b/src/pmotools/pmo_builder/metatable_to_pmo.py index 4162c22..6169646 100644 --- a/src/pmotools/pmo_builder/metatable_to_pmo.py +++ b/src/pmotools/pmo_builder/metatable_to_pmo.py @@ -157,7 +157,7 @@ def library_sample_info_table_to_pmo( library_prep_plate_position_col, meta_json, copy_contents, - "specimen_name", + "library_sample_name", "library_prep_plate_info", ) meta_json = add_parasite_density_info( @@ -166,7 +166,7 @@ def library_sample_info_table_to_pmo( meta_json, copy_contents, "library_sample_name", - entry_name="parasite_density_info", + entry_name="qpcr_parasite_density_info", ) # listify columns that contain values that could be list, are delimited by the argument list_values_library_values_delimiter primitives = (int, float, str, bool, complex) @@ -494,7 +494,7 @@ def add_plate_info( plate_position_col, meta_json, df, - specimen_name_col, + match_col, entry_name="plate_info", ): if all( @@ -531,7 +531,7 @@ def add_plate_info( ) from e for row in meta_json: - content_row = df[df[specimen_name_col] == row[specimen_name_col]] + content_row = df[df[match_col] == row[match_col]] plate_name_val = content_row[plate_name_col].iloc[0] if plate_name_col else None plate_row_val = ( content_row[plate_row_col].iloc[0].upper() if plate_row_col else None @@ -560,7 +560,7 @@ def add_parasite_density_info( parasite_density_method_col, meta_json, df, - specimen_name_col, + match_col, entry_name, ): density_method_pairs = [] @@ -608,7 +608,7 @@ def add_parasite_density_info( # Add parasite density info to meta_json for row in meta_json: - content_row = df[df[specimen_name_col] == row[specimen_name_col]] + content_row = df[df[match_col] == row[match_col]] density_infos = [] for density_col, method_col in density_method_pairs: density_val = content_row[density_col].iloc[0] if density_col else None diff --git a/tests/test_pmo_builder/test_metatable_to_pmo.py b/tests/test_pmo_builder/test_metatable_to_pmo.py index 38d59bc..7223cca 100644 --- a/tests/test_pmo_builder/test_metatable_to_pmo.py +++ b/tests/test_pmo_builder/test_metatable_to_pmo.py @@ -1603,13 +1603,18 @@ def test_library_sample_info_table_to_pmo_with_parasite_density(self): parasite_density_method_col="parasite_density_method", ) - self.assertEqual(result[0]["parasite_density_info"][0]["parasite_density"], 10) self.assertEqual( - result[0]["parasite_density_info"][0]["parasite_density_method"], "qPCR" + result[0]["qpcr_parasite_density_info"][0]["parasite_density"], 10 ) - self.assertEqual(result[1]["parasite_density_info"][0]["parasite_density"], 100) self.assertEqual( - result[1]["parasite_density_info"][0]["parasite_density_method"], + result[0]["qpcr_parasite_density_info"][0]["parasite_density_method"], + "qPCR", + ) + self.assertEqual( + result[1]["qpcr_parasite_density_info"][0]["parasite_density"], 100 + ) + self.assertEqual( + result[1]["qpcr_parasite_density_info"][0]["parasite_density_method"], "microscopy", ) @@ -1638,10 +1643,18 @@ def test_library_sample_info_table_to_pmo_with_parasite_density_multiple(self): parasite_density_method_col=["method1", "method2"], ) - self.assertEqual(result[0]["parasite_density_info"][0]["parasite_density"], 15) - self.assertEqual(result[0]["parasite_density_info"][1]["parasite_density"], 10) - self.assertEqual(result[1]["parasite_density_info"][0]["parasite_density"], 107) - self.assertEqual(result[1]["parasite_density_info"][1]["parasite_density"], 100) + self.assertEqual( + result[0]["qpcr_parasite_density_info"][0]["parasite_density"], 15 + ) + self.assertEqual( + result[0]["qpcr_parasite_density_info"][1]["parasite_density"], 10 + ) + self.assertEqual( + result[1]["qpcr_parasite_density_info"][0]["parasite_density"], 107 + ) + self.assertEqual( + result[1]["qpcr_parasite_density_info"][1]["parasite_density"], 100 + ) def test_library_sample_info_table_to_pmo_with_all_new_fields(self): """Test all new optional fields together""" @@ -1685,7 +1698,9 @@ def test_library_sample_info_table_to_pmo_with_all_new_fields(self): self.assertEqual(result[0]["experiment_accession"], "EXP001") self.assertEqual(result[0]["fastqs_loc"], "/path/to/fastqs1") self.assertEqual(result[0]["run_accession"], "RUN001") - self.assertEqual(result[0]["parasite_density_info"][0]["parasite_density"], 10) + self.assertEqual( + result[0]["qpcr_parasite_density_info"][0]["parasite_density"], 10 + ) self.assertIn("library_prep_plate_info", result[0]) self.assertEqual(result[0]["library_prep_plate_info"]["plate_col"], 1) @@ -1693,7 +1708,9 @@ def test_library_sample_info_table_to_pmo_with_all_new_fields(self): self.assertEqual(result[1]["experiment_accession"], "EXP002") self.assertEqual(result[1]["fastqs_loc"], "/path/to/fastqs2") self.assertEqual(result[1]["run_accession"], "RUN002") - self.assertEqual(result[1]["parasite_density_info"][0]["parasite_density"], 100) + self.assertEqual( + result[1]["qpcr_parasite_density_info"][0]["parasite_density"], 100 + ) self.assertIn("library_prep_plate_info", result[1]) self.assertEqual(result[1]["library_prep_plate_info"]["plate_col"], 2) From e45234f48914ffb4a98e49643b4af7d695058ec9 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 15:04:21 -0700 Subject: [PATCH 09/12] two bugs in combine_pmos fixed; --- src/pmotools/pmo_engine/pmo_reader.py | 10 +++- tests/test_pmo_engine/test_pmo_reader.py | 67 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/pmotools/pmo_engine/pmo_reader.py b/src/pmotools/pmo_engine/pmo_reader.py index 91a6e46..deb7930 100644 --- a/src/pmotools/pmo_engine/pmo_reader.py +++ b/src/pmotools/pmo_engine/pmo_reader.py @@ -394,10 +394,11 @@ def combine_multiple_pmos(pmos: list[dict]): pmo["bioinformatics_run_info"] ): bioinformatics_run_info_copy = copy.deepcopy(bioinformatics_run_info) + # remap using the run's own methods id, not its position index bioinformatics_run_info_copy[ "bioinformatics_methods_id" ] = bioinformatics_methods_info_old_index_key[pmo_index][ - bioinformatics_run_info_index + bioinformatics_run_info_copy["bioinformatics_methods_id"] ] if "bioinformatics_run_info" not in pmo_out: pmo_out["bioinformatics_run_info"] = [] @@ -501,8 +502,13 @@ def combine_multiple_pmos(pmos: list[dict]): new_mhaps_target_index = len( pmo_out["representative_microhaplotypes"]["targets"] ) + new_rep_target = copy.deepcopy(representative_microhaplotypes) + # remap the new target's target_id to the combined target_info + new_rep_target["target_id"] = target_info_old_index_key[pmo_index][ + new_rep_target["target_id"] + ] pmo_out["representative_microhaplotypes"]["targets"].append( - copy.deepcopy(representative_microhaplotypes) + new_rep_target ) representative_microhaplotypes_old_index_key[pmo_index][ representative_microhaplotypes_index diff --git a/tests/test_pmo_engine/test_pmo_reader.py b/tests/test_pmo_engine/test_pmo_reader.py index b612ae2..0075a48 100755 --- a/tests/test_pmo_engine/test_pmo_reader.py +++ b/tests/test_pmo_engine/test_pmo_reader.py @@ -165,6 +165,73 @@ def test_combine_multiple_pmos_fail_dup_library_sample_names(self): ) self.assertRaises(Exception, PMOReader.combine_multiple_pmos, pmo_data_list_2) + def test_combine_multiple_pmos_remaps_new_rep_target_id(self): + # a representative-microhaplotype target newly added from a + # non-first PMO must have its target_id remapped to the combined + # target_info (previously it kept its local target_id), this test + # ensures this is tested as it was previously untested (2026-06-29) + import pandas as pd + from pmotools.pmo_builder.mhap_table_to_pmo import mhap_table_to_pmo + from pmotools.pmo_builder.panel_information_to_pmo import ( + panel_info_table_to_pmo, + ) + from pmotools.pmo_builder.merge_to_pmo import merge_to_pmo + + def build(libs, targets, seqs, panel_name, panel_targets): + mhap_info = mhap_table_to_pmo( + pd.DataFrame( + { + "library_sample_name": libs, + "target_name": targets, + "seq": seqs, + "reads": [10] * len(libs), + } + ) + ) + panel_info = panel_info_table_to_pmo( + pd.DataFrame( + { + "target_name": panel_targets, + "fwd_primer": [ + "A" * (i + 4) for i in range(len(panel_targets)) + ], + "rev_primer": [ + "C" * (i + 4) for i in range(len(panel_targets)) + ], + } + ), + panel_name, + ) + return merge_to_pmo(mhap_info=mhap_info, panel_target_info=panel_info) + + pmo_a = build( + ["S1", "S2"], ["t1", "t2"], ["AAA", "GGG"], "panelA", ["t1", "t2"] + ) + # pmo_b introduces a brand-new target t3 + pmo_b = build( + ["S3", "S4"], ["t1", "t3"], ["AAA", "CCC"], "panelB", ["t1", "t3"] + ) + + combined = PMOReader.combine_multiple_pmos([pmo_a, pmo_b]) + self.assertEqual( + [t["target_name"] for t in combined["target_info"]], + ["t1", "t2", "t3"], + ) + # every rep target's target_id resolves to the right combined target + for rep in combined["representative_microhaplotypes"]["targets"]: + self.assertLess(rep["target_id"], len(combined["target_info"])) + t3_reps = [ + rep + for rep in combined["representative_microhaplotypes"]["targets"] + if combined["target_info"][rep["target_id"]]["target_name"] == "t3" + ] + self.assertEqual(len(t3_reps), 1) + # validate against schema (minimal builder PMO targets v1.1.0) + checker = PMOChecker( + load_schema("portable_microhaplotype_object_v1.1.0.schema.json") + ) + checker.validate_pmo_json(combined) + def test_combine_multiple_pmos_fail_for_combine_only_one_file(self): # will fail for only having 1 PMO pmo_data_list_2 = PMOReader.read_in_pmos( From 8b32cda59ece4ed449c2bf32f6fd3c97014fd035 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 15:48:33 -0700 Subject: [PATCH 10/12] fixed a left over old schema version where all fields were copies over, now checks for optionally ones; --- src/pmotools/pmo_engine/pmo_processor.py | 24 +++++++++++------- tests/test_pmo_engine/test_pmo_processor.py | 28 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/pmotools/pmo_engine/pmo_processor.py b/src/pmotools/pmo_engine/pmo_processor.py index cef95fb..722cd7a 100644 --- a/src/pmotools/pmo_engine/pmo_processor.py +++ b/src/pmotools/pmo_engine/pmo_processor.py @@ -1073,23 +1073,29 @@ def extract_from_pmo_with_read_filter(pmodata, read_filter: float): pmo_out = { "pmo_header": copy.deepcopy(pmodata["pmo_header"]), "panel_info": copy.deepcopy(pmodata["panel_info"]), - "sequencing_info": copy.deepcopy(pmodata["sequencing_info"]), "target_info": copy.deepcopy(pmodata["target_info"]), "specimen_info": copy.deepcopy(pmodata["specimen_info"]), "library_sample_info": copy.deepcopy(pmodata["library_sample_info"]), - "project_info": copy.deepcopy(pmodata["project_info"]), - "targeted_genomes": copy.deepcopy(pmodata["targeted_genomes"]), "representative_microhaplotypes": copy.deepcopy( pmodata["representative_microhaplotypes"] ), - "bioinformatics_methods_info": copy.deepcopy( - pmodata["bioinformatics_methods_info"] - ), - "bioinformatics_run_info": copy.deepcopy( - pmodata["bioinformatics_run_info"] - ), "detected_microhaplotypes": [], } + # only copy optional sections if they are present (no longer required by the schema) + if "sequencing_info" in pmodata: + pmo_out["sequencing_info"] = copy.deepcopy(pmodata["sequencing_info"]) + if "project_info" in pmodata: + pmo_out["project_info"] = copy.deepcopy(pmodata["project_info"]) + if "targeted_genomes" in pmodata: + pmo_out["targeted_genomes"] = copy.deepcopy(pmodata["targeted_genomes"]) + if "bioinformatics_methods_info" in pmodata: + pmo_out["bioinformatics_methods_info"] = copy.deepcopy( + pmodata["bioinformatics_methods_info"] + ) + if "bioinformatics_run_info" in pmodata: + pmo_out["bioinformatics_run_info"] = copy.deepcopy( + pmodata["bioinformatics_run_info"] + ) # if has optional read_counts_by_stage then add as well # if does contain, @todo consider updating with new counts now that a filter has been applied diff --git a/tests/test_pmo_engine/test_pmo_processor.py b/tests/test_pmo_engine/test_pmo_processor.py index 2c4a41b..5ce6d31 100755 --- a/tests/test_pmo_engine/test_pmo_processor.py +++ b/tests/test_pmo_engine/test_pmo_processor.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import copy import os import tempfile import unittest @@ -317,10 +318,35 @@ def test_extract_from_pmo_with_read_filter(self): ) with open(output_fnp, "w") as f: json.dump(pmo_data_filtered, f) - self.assertEqual("879b1a0c62a77a8fcc910a21d8ec9de6", md5sum_of_fnp(output_fnp)) + self.assertEqual("59e24e7efd4cd7822acde70828ad6119", md5sum_of_fnp(output_fnp)) checker = PMOChecker(self.pmo_jsonschema_data) checker.validate_pmo_json(pmo_data_filtered) + def test_extract_from_pmo_with_read_filter_missing_optional_sections(self): + # the v1.1.0 schema relaxed the required fields, so a PMO may omit these + # optional sections; the extracted PMO must not copy them in as null/empty keys + optional_sections = [ + "sequencing_info", + "project_info", + "targeted_genomes", + "bioinformatics_methods_info", + "bioinformatics_run_info", + "read_counts_by_stage", + ] + stripped_pmo_data = copy.deepcopy(self.combined_pmo_data) + for section in optional_sections: + stripped_pmo_data.pop(section, None) + pmo_data_filtered = PMOProcessor.extract_from_pmo_with_read_filter( + stripped_pmo_data, 1000 + ) + for section in optional_sections: + self.assertNotIn(section, pmo_data_filtered) + # the relaxed required fields are a v1.1.0 change, so validate against v1.1.0 + checker = PMOChecker( + load_schema("portable_microhaplotype_object_v1.1.0.schema.json") + ) + checker.validate_pmo_json(pmo_data_filtered) + def test_filter_pmo_by_target_ids(self): pmo_data_select_targets = PMOProcessor.filter_pmo_by_target_ids( self.combined_pmo_data, {1, 10, 11, 55} From 6bb05646d7cf24f5420321730a70d6996bf61ef9 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Mon, 29 Jun 2026 16:45:40 -0700 Subject: [PATCH 11/12] small fixes for handling minimum format; added more robust unittests for the v1.1.0 files; --- src/pmotools/pmo_engine/pmo_exporter.py | 12 +++++-- src/pmotools/pmo_engine/pmo_processor.py | 4 +++ tests/data/minimum_Furstenau2025_PMO.json.gz | Bin 0 -> 18894 bytes tests/test_pmo_engine/test_pmo_exporter.py | 32 +++++++++++++++++++ tests/test_pmo_engine/test_pmo_processor.py | 28 ++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/data/minimum_Furstenau2025_PMO.json.gz diff --git a/src/pmotools/pmo_engine/pmo_exporter.py b/src/pmotools/pmo_engine/pmo_exporter.py index 361ebd7..66238c7 100644 --- a/src/pmotools/pmo_engine/pmo_exporter.py +++ b/src/pmotools/pmo_engine/pmo_exporter.py @@ -121,10 +121,14 @@ def export_specimen_meta_table(pmodata, separator: str = ",") -> pd.DataFrame: for specimen in pmodata["specimen_info"]: export_row = {} for key, value in specimen.items(): - if "project_id" == key: + if "project_id" == key and "project_info" in pmodata: export_row["project_name"] = pmodata["project_info"][value][ "project_name" ] + elif "project_id" == key: + # project_info is optional as of schema v1.1.0; keep the raw id + # rather than failing to resolve a name that isn't available + export_row[key] = value elif PMOExporter._is_primitive(value): export_row[key] = value elif PMOExporter._is_primitive_list(value): @@ -145,10 +149,14 @@ def export_library_sample_meta_table(pmodata, separator: str = ",") -> pd.DataFr for library_sample in pmodata["library_sample_info"]: export_row = {} for key, value in library_sample.items(): - if "sequencing_info_id" == key: + if "sequencing_info_id" == key and "sequencing_info" in pmodata: export_row["sequencing_info_name"] = pmodata["sequencing_info"][ value ]["sequencing_info_name"] + elif "sequencing_info_id" == key: + # sequencing_info is optional as of schema v1.1.0; keep the raw id + # rather than failing to resolve a name that isn't available + export_row[key] = value elif "specimen_id" == key: export_row["specimen_name"] = pmodata["specimen_info"][value][ "specimen_name" diff --git a/src/pmotools/pmo_engine/pmo_processor.py b/src/pmotools/pmo_engine/pmo_processor.py index 722cd7a..c452916 100644 --- a/src/pmotools/pmo_engine/pmo_processor.py +++ b/src/pmotools/pmo_engine/pmo_processor.py @@ -20,6 +20,10 @@ def get_index_key_of_bioinformatics_run_names(pmodata): :return: a dictionary of indexes keyed by bioinformatics_run_name """ ret = {} + # bioinformatics_run_info is optional as of schema v1.1.0; a PMO without it + # simply has no run-name to index mapping + if "bioinformatics_run_info" not in pmodata: + return ret for idx, bioinformatics_run in enumerate(pmodata["bioinformatics_run_info"]): ret[bioinformatics_run["bioinformatics_run_name"]] = idx return ret diff --git a/tests/data/minimum_Furstenau2025_PMO.json.gz b/tests/data/minimum_Furstenau2025_PMO.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..b87e2e68517c4deb2c2bc9584534191f3c60dde5 GIT binary patch literal 18894 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 BqwoL# literal 0 HcmV?d00001 diff --git a/tests/test_pmo_engine/test_pmo_exporter.py b/tests/test_pmo_engine/test_pmo_exporter.py index 1984bfa..89c8dfa 100755 --- a/tests/test_pmo_engine/test_pmo_exporter.py +++ b/tests/test_pmo_engine/test_pmo_exporter.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import copy import gzip import hashlib import os @@ -38,6 +39,15 @@ def setUp(self): "rt", ) as f: self.minimum_fields_v1_1_0_pmo_data = json.load(f) + # a real, schema-valid v1.1.0 PMO with only the required top-level sections + with gzip.open( + os.path.join( + os.path.dirname(self.working_dir), + "data/minimum_Furstenau2025_PMO.json.gz", + ), + "rt", + ) as f: + self.minimum_v1_1_0_pmo_data = json.load(f) def tearDown(self): self.test_dir.cleanup() @@ -245,6 +255,28 @@ def test_export_library_sample_meta_table(self): md5sum_of_fnp(os.path.join(self.test_dir.name, "library_sample_table.csv")), ) + def test_export_meta_tables_without_optional_cross_ref_sections(self): + # project_info and sequencing_info are optional as of v1.1.0; the specimen and + # library_sample meta-table exporters must not raise a raw KeyError when a + # referencing id is present but the referenced optional section is absent + self.assertNotIn("project_info", self.minimum_v1_1_0_pmo_data) + self.assertNotIn("sequencing_info", self.minimum_v1_1_0_pmo_data) + # a clean minimal PMO (no dangling ids) should export fine + PMOExporter.export_specimen_meta_table(self.minimum_v1_1_0_pmo_data) + PMOExporter.export_library_sample_meta_table(self.minimum_v1_1_0_pmo_data) + + # inject the referencing ids without the optional sections (referential dangling) + # the raw id is kept rather than resolving an unavailable name + dangling = copy.deepcopy(self.minimum_v1_1_0_pmo_data) + dangling["specimen_info"][0]["project_id"] = 0 + dangling["library_sample_info"][0]["sequencing_info_id"] = 0 + spec_table = PMOExporter.export_specimen_meta_table(dangling) + self.assertIn("project_id", spec_table.columns) + self.assertNotIn("project_name", spec_table.columns) + library_table = PMOExporter.export_library_sample_meta_table(dangling) + self.assertIn("sequencing_info_id", library_table.columns) + self.assertNotIn("sequencing_info_name", library_table.columns) + def test_export_sequencing_info_meta_table(self): sequencing_info_table = PMOExporter.export_sequencing_info_meta_table( self.small_example_pmo_data diff --git a/tests/test_pmo_engine/test_pmo_processor.py b/tests/test_pmo_engine/test_pmo_processor.py index 5ce6d31..2a48ba8 100755 --- a/tests/test_pmo_engine/test_pmo_processor.py +++ b/tests/test_pmo_engine/test_pmo_processor.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import copy +import gzip import os import tempfile import unittest @@ -37,6 +38,16 @@ def setUp(self): ) ) as f: self.minimum_pmo_data = json.load(f) + # a real, schema-valid v1.1.0 PMO that contains only the required top-level + # sections (no sequencing_info/project_info/targeted_genomes/bioinformatics_*/ + # read_counts_by_stage) to guard against unguarded optional-section access + with gzip.open( + os.path.join( + os.path.dirname(self.working_dir), + "data/minimum_Furstenau2025_PMO.json.gz", + ) + ) as f: + self.minimum_v1_1_0_pmo_data = json.load(f) def tearDown(self): self.test_dir.cleanup() @@ -347,6 +358,23 @@ def test_extract_from_pmo_with_read_filter_missing_optional_sections(self): ) checker.validate_pmo_json(pmo_data_filtered) + def test_bioinformatics_run_name_lookups_without_run_info(self): + # bioinformatics_run_info is optional as of v1.1.0; the run-name lookups + # must not raise a raw KeyError on a valid PMO that omits it + self.assertNotIn("bioinformatics_run_info", self.minimum_v1_1_0_pmo_data) + self.assertEqual( + {}, + PMOProcessor.get_index_key_of_bioinformatics_run_names( + self.minimum_v1_1_0_pmo_data + ), + ) + self.assertEqual( + [], + PMOProcessor.get_sorted_bioinformatics_run_names( + self.minimum_v1_1_0_pmo_data + ), + ) + def test_filter_pmo_by_target_ids(self): pmo_data_select_targets = PMOProcessor.filter_pmo_by_target_ids( self.combined_pmo_data, {1, 10, 11, 55} From 336184665e02a6f32463d091aade272e643c7192 Mon Sep 17 00:00:00 2001 From: Nicholas Hathaway Date: Tue, 21 Jul 2026 14:11:00 -0700 Subject: [PATCH 12/12] change ps_chrom_col to pseduocigar_chrom_col --- src/pmotools/pmo_builder/mhap_table_to_pmo.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pmotools/pmo_builder/mhap_table_to_pmo.py b/src/pmotools/pmo_builder/mhap_table_to_pmo.py index 70e4a57..fb00da7 100644 --- a/src/pmotools/pmo_builder/mhap_table_to_pmo.py +++ b/src/pmotools/pmo_builder/mhap_table_to_pmo.py @@ -262,12 +262,14 @@ def create_representative_microhaplotype_dict( # the pseudocigar ref_loc shares the chromosome / genome with the # microhaplotype location by default, but each may use its own column - ps_chrom_col = pseudocigar_chrom_col if pseudocigar_chrom_col else chrom_col + pseudocigar_chrom_col = ( + pseudocigar_chrom_col if pseudocigar_chrom_col else chrom_col + ) ps_genome_id = ( pseudocigar_genome_id if pseudocigar_genome_id is not None else genome_id ) if pseudocigar_col and not ( - ps_chrom_col and pseudocigar_start_col and pseudocigar_end_col + pseudocigar_chrom_col and pseudocigar_start_col and pseudocigar_end_col ): raise ValueError( "pseudocigar_col is set, so a Pseudocigar ref_loc must be " @@ -327,7 +329,7 @@ def warn_if_duplicated_seqs(df, target_col, seq_col): alt_annotations_col, microhaplotype_name_col, pseudocigar_col, - ps_chrom_col, + pseudocigar_chrom_col, pseudocigar_start_col, pseudocigar_end_col, pseudocigar_ref_seq_col, @@ -390,7 +392,7 @@ def warn_if_duplicated_seqs(df, target_col, seq_col): mhap["microhaplotype_name"] = val if val := get_if_present(row, pseudocigar_col): if not ( - pd.notna(row[ps_chrom_col]) + pd.notna(row[pseudocigar_chrom_col]) and pd.notna(row[pseudocigar_start_col]) and pd.notna(row[pseudocigar_end_col]) ): @@ -401,7 +403,7 @@ def warn_if_duplicated_seqs(df, target_col, seq_col): ) ref_loc = { "genome_id": ps_genome_id, - "chrom": row[ps_chrom_col], + "chrom": row[pseudocigar_chrom_col], "start": row[pseudocigar_start_col], "end": row[pseudocigar_end_col], }