ugbio-consensus 1.34.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ Metadata-Version: 2.4
2
+ Name: ugbio_consensus
3
+ Version: 1.34.0
4
+ Summary: Ultima Genomics consensus (ReadFuserAlignSort) duplicate marking, performance & duplex reports
5
+ Author-email: Gat Krieger <gat.krieger@ultimagen.com>, Doron Shem-Tov <doron.shemtov@ultimagen.com>
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: ugbio_core[vcfbed]
10
+ Requires-Dist: ugbio_cloud_utils
11
+ Requires-Dist: pysam>=0.22.0
12
+ Requires-Dist: boto3
13
+
14
+ # ugbio_consensus
15
+
16
+ Duplicate marking and performance & duplex reports for the **ReadFuserAlignSort**
17
+ (consensus tool) pipeline at Ultima Genomics.
18
+
19
+ | Entry point | Runs | Role |
20
+ |-------------|------|------|
21
+ | `mark_duplicates_mi` | *before* `read_fuser` | Assign `MI`/`DS` duplicate families, and with `--use-umi` the `CS` cross-strand duplex tag. |
22
+ | `mark_duplex_consensus` | *after* the consensus is sorted | Write `dx=1` on the consensus reads of CS families whose `po` shows both orientations (F1R2 and F2R1). |
23
+ | `pair_consensus_reads` | *after* `read_fuser`, before alignment | Re-pair the two strand-consensus reads of each pair (shared name, R1/R2 flags) so UA 4.x aligns them as a pair. |
24
+ | `consensus_report` | *after* the pipeline | Per-sample HTML report of alignment, duplication, coverage and duplex family metrics. |
25
+
26
+ ## mark_duplicates_mi
27
+
28
+ Marks duplicate families on a coordinate-sorted BAM/CRAM and writes, on every
29
+ record of a family:
30
+
31
+ ```
32
+ MI:Z:<run-id>-<10-digit family id> the duplicate family (molecular identifier)
33
+ DS:i:<family size> number of pairs in that family
34
+ CS:Z:<run-id>-<10-digit family id> --use-umi only: the cross-strand duplex group
35
+ ```
36
+
37
+ `--use-umi` adds `(R1.u5, R2.u3)` to the dedup key alongside the position, so the
38
+ two strands of one duplex molecule land in **separate** `MI` families — which is
39
+ what we want, since each strand is consensus-called on its own. The cost is that
40
+ the duplex relationship is erased: `read_fuser` groups strictly on `MI`, so
41
+ `duplex_consensus` would be 0 by construction. `CS` restores the link.
42
+
43
+ Two clusters are cross-strand partners iff they share the positional key, their
44
+ UMI pairs are exact mutual reverses, **and** they sit on opposite strands. That
45
+ last term is read off the FLAG (`read.is_reverse`), not off which mate starts
46
+ further left: on a real library the two disagree for 10.5 % of pairs, because
47
+ fragments shorter than the read dovetail.
48
+
49
+ `CS` is **total** — a cluster with no partner gets `CS` equal to its own `MI`
50
+ rather than no tag at all. That matters downstream, because `demux --umi=CS`
51
+ treats a missing tag as the empty value, and every `CS`-less read at a position
52
+ would then collapse into one duplicate family. So `CS == MI` reads as "clustered,
53
+ but no cross-strand partner was found", and `CS != MI` as "duplex partner found".
54
+
55
+ Two opt-in options (both off by default, so the default output is unchanged) merge
56
+ clusters that are the same molecule into one `MI` family:
57
+
58
+ - `--position-tolerance BP` merges same-UMI clusters whose fragment boundaries differ
59
+ by at most `BP` (read1/read2 use asymmetric adapters, so one molecule's copies can map
60
+ 1-3 bp apart). Drifts chain transitively.
61
+ - `--cross-strand` also merges the F1R2/F2R1 strands of a duplex molecule into one `MI`,
62
+ using the same orientation-aware rule as `CS`. Boundaries must match within
63
+ `--position-tolerance`.
64
+ - `--orientation-in-key` does the opposite: the pair orientation (F1R2/F2R1/FF/RR) joins the
65
+ dedup key, so the two strands of one molecule never share an `MI`. It matters for a symmetric
66
+ UMI (`u5 == u3`), where both strands otherwise get the same key and so one `MI`. `CS` still links
67
+ the strands. On by default with `--use-umi`; `--no-orientation-in-key` turns it off, and
68
+ `--cross-strand` does so implicitly (forcing both is an error).
69
+ Without `--use-umi` it can still be forced on; `CS` is then the position family: every
70
+ orientation family at one (chrom, left_start, right_end) shares a `CS`, which over-links
71
+ independent molecules at the same place.
72
+
73
+ ```bash
74
+ # whole-file
75
+ mark_duplicates_mi input.bam output.bam --use-umi
76
+
77
+ # sharded, which is what the WDL task runs (--jobs = cpu, shards = panel intervals)
78
+ mark_duplicates_mi input.cram output.cram --use-umi \
79
+ --reference Homo_sapiens_assembly38.fasta \
80
+ --jobs 16 --regions xgen-pan-cancer-targets.hg38.bed
81
+ ```
82
+
83
+ Family-size histogram goes to stdout; `--stats-only` skips writing the output file.
84
+ Re-marking is idempotent, and re-marking **without** `--use-umi` strips any `CS`
85
+ the input carried (a stale `CS` claims a duplex link the current key does not
86
+ support, which is worse than no `CS`).
87
+
88
+ `--pair-orientation-tag po` also writes each primary read's pair orientation
89
+ (`F1R2`, `F2R1`, `FF` or `RR`) to that tag. The mates of this library carry no
90
+ mate fields (no `RNEXT`, no `0x20`), so orientation only exists where the two
91
+ are paired by name, which this tool does anyway for `MI`; doing it here costs
92
+ no extra pass. A read whose mate is absent or on another contig, or in sharded
93
+ mode further than `--pad` away, gets no tag, and a stale one is dropped. Like
94
+ `CS`, it reaches the consensus reads only if listed in `read_fuser --umi-tags`.
95
+
96
+ ### Downstream contract (BIOIN-3068)
97
+
98
+ `CS` only reaches the consensus reads if every stage is told to carry it:
99
+
100
+ 1. `read_fuser --umi-tags u3,u5,CS` — the default list is `u5,u3`, so **without
101
+ this `CS` is silently dropped** and the run still completes green.
102
+ 2. `sorter_params`: `umi_tag: "CS"` and `mark_duplicates_ends_read_uncertainty: 50`.
103
+ `umi_tag` must be `CS` **alone** — `read_fuser` copies `u5`/`u3` onto each
104
+ consensus read from its own strand's original read, and the two strands carry
105
+ them swapped, so adding them to the key splits exactly the pair `CS` exists to
106
+ join. The wide 50 bp window is needed because the two strands' consensus reads
107
+ routinely end ~27 bp apart, and it is safe only because `CS` is in the key: no
108
+ `(CS, alignment strand)` cell holds more than 2 reads, so there is nothing to
109
+ over-merge. Measured on 36,591 consensus reads: default recovers 424 of 3,423
110
+ true pairs (12.4 %), `ends_read_uncertainty: 50` recovers 3,307 (96.6 %),
111
+ precision 100 % both ways.
112
+ 3. For SRSNV/DeepSRSNV, add `"CS:Z"` to `cram_tags_to_copy`.
113
+
114
+ > ⚠️ This module is verified byte-for-byte against a 12,670,749-record baseline
115
+ > (`MI`/`DS` identical to the pre-`CS` output, 55,884 `CS` links, 0 gained / 0
116
+ > lost). The one deliberate deviation is `--regions`: a read that starts upstream of an
117
+ > interval and reaches into it is now emitted (as `samtools view -L` would), which adds
118
+ > ~30 of 12.67 M records versus that baseline. Do not refactor it — including "just" reordering the `set_tag` calls,
119
+ > which fixes the emitted aux order as `MI, DS, CS` — without re-running
120
+ > `/data/Runs/BIOIN-3068/cmp_stream.py` and `cs_links.py`.
121
+
122
+ ## mark_duplex_consensus
123
+
124
+ Writes `dx:i:1` on the consensus reads of duplex families. `DS` cannot say this: the sorter gives
125
+ `DS=2` to two consensus reads of one orientation as readily as to the two strands of a duplex
126
+ molecule. `dx` reads the orientation instead: a `CS` family is duplex when its consensus reads
127
+ include both `po=F1R2` and `po=F2R1`, and then every read of the family gets `dx=1`.
128
+
129
+ ```bash
130
+ mark_duplex_consensus consensus.cram consensus.dx.cram -T Homo_sapiens_assembly38.fasta --threads 8
131
+ ```
132
+
133
+ The input is coordinate-sorted and is streamed with a window of `--pad` bp (default 2000); a family
134
+ whose orientations start further apart than that is not seen as duplex. Reads of other families
135
+ get no `dx`, a stale `dx` is replaced, reads without `CS` and
136
+ secondary or supplementary records are left as they are. The output is indexed.
137
+
138
+ ## pair_consensus_reads
139
+
140
+ After `unpair_reads_by_strand` each strand of a pair gets its own single-end consensus read, named
141
+ `<stem>0` (forward) and `<stem>1` (reverse) with the same `CS` and `po`. Aligned alone, one of them can
142
+ land with a low MAPQ in a repeat that its mate would have resolved. This writes the two as one unaligned
143
+ pair: R1 is the forward read for `po=F1R2` and the reverse read for `F2R1` (flags 77/141), both are
144
+ named `<stem>`, and the reverse read is reverse-complemented because a mate is handed to the aligner as
145
+ sequenced. `tp`/`t0` are left alone (the read group declares them reference-oriented).
146
+
147
+ ```bash
148
+ pair_consensus_reads consensus.bam consensus.paired.bam --counters pair_counters.txt
149
+ ```
150
+
151
+ Reads without a partner, with another `po`, or whose partners disagree on `po` or `CS` are written
152
+ unchanged. The input need not be sorted: reads are spread over `--buckets` temporary files by stem.
153
+
154
+ Needs UA 4.x (`--unpaired-cost`, `--max-proper-tlen`); UA 3.x has no paired mode. A 3.x index is
155
+ read as is, with identical alignments. UA sets the strand and pair flags, so do not run the nf/nr strand
156
+ step on paired reads.
157
+
158
+ ## unpair_rename_by_strand
159
+
160
+ Turns a paired-end, duplicate-marked BAM/CRAM into a single-end one whose reads carry their strand, so the
161
+ consensus tool builds every family per strand. Each primary read gets `0` (forward) or `1` (reverse) appended
162
+ to its name and to `MI`; the pair flags and mate fields are cleared. `CS` is left as it is, since it is the
163
+ only link between the two strands of a molecule. Supplementary and secondary records are written unchanged.
164
+
165
+ ```bash
166
+ unpair_rename_by_strand in.bam out.bam -T ref.fa --threads 8 --pair-orientation-tag po \
167
+ --counters unpair_rename_counters.txt
168
+ ```
169
+
170
+ With `--pair-orientation-tag` the orientation of each pair (`F1R2`, `F2R1`, `FF`, `RR`) is written first, by
171
+ pairing the mates on name within `--pair-window` bases of the sorted input; reads without a mate in reach get
172
+ no tag. The file is cut into `--shard-size` windows that run in parallel, and the output does not depend on the
173
+ window size. The input needs an index (built when missing).
174
+
175
+ ## set_consensus_strand
176
+
177
+ read_fuser emits every consensus read reference-forward. This sets 0x10 from the `nf`/`nr` majority (`nr > nf`
178
+ is reverse, a tie is forward) and changes nothing else. Supplementary, secondary and paired records and reads
179
+ without `nf`/`nr` are left alone.
180
+
181
+ ```bash
182
+ set_consensus_strand consensus.bam consensus.strand.bam --counters strand_flag_counters.txt --require-cs-tag
183
+ ```
184
+
185
+ Exit status is 1 if a record has no `nf`/`nr`, or, with `--require-cs-tag`, if a consensus read has no `CS`.
186
+
187
+ ## consensus_report
188
+ The consensus step (`read_fuser`) fuses the reads of each UMI/MI family into a
189
+ single consensus read and records, on that read:
190
+
191
+ ```
192
+ rn:Z:<comma-separated list of the fused query names>
193
+ nf:i:<n_forward_strand_reads>
194
+ nr:i:<n_reverse_strand_reads>
195
+ ```
196
+
197
+ i.e. which reads were fused, and how many of them were on the forward (+) and
198
+ reverse (−) strand. From these tags the report classifies every consensus read
199
+ and measures family size **directly** (no MI re-grouping needed):
200
+
201
+ | Category | Condition | Family size |
202
+ |----------|-----------|-------------|
203
+ | both-strands **duplex** | `nf > 0` and `nr > 0` | `nf + nr` |
204
+ | **single-strand** duplicate | exactly one of `nf`/`nr` is 0 | the non-zero count |
205
+ | **singleton** / pass-through | no `rn` tag | 1 |
206
+
207
+ `nf + nr` equals the number of names in `rn`, so the two encodings agree on
208
+ family size; the report cross-checks this and warns on a mismatch.
209
+
210
+ > **A consensus read is identified by the presence of `rn`, never by a strand
211
+ > tag.** Trimmer already emits `rs:i` ("start position in input of segment ...",
212
+ > see the `@CO` lines in the input header) on the *input* reads, and that tag
213
+ > survives onto the reads the consensus step passes through unchanged. An earlier
214
+ > read_fuser wrote the strand counts as `fs`/`rs`, which collided with it and made
215
+ > every pass-through read look like a consensus read; the counts were renamed to
216
+ > `nf`/`nr` to break the collision. Keying off `rn` is collision-proof and also
217
+ > handles the fact that `nf` is simply *absent* (not zero) on pass-through reads —
218
+ > a parquet reader would fill that absence as `0`.
219
+
220
+ ## What the report summarises (per sample)
221
+
222
+ - **Sorter QC** — alignment, duplication and coverage metrics from
223
+ `sorter_stats_csv` (post-consensus).
224
+ - **Duplex family metrics** — average MI-family size *and* covered depth for
225
+ duplex families and for single-strand duplicate families, from the `nf`/`nr` tags.
226
+ These are scanned over one chromosome (`--duplex-chrom`, default `chr20`) — a
227
+ representative sample that avoids reading the whole (very large) CRAM. When a
228
+ targets BED is given, the scan is restricted to the targeted intervals on that
229
+ chromosome.
230
+ - **On-target metrics (optional)** — when a `--targets` BED is supplied
231
+ (e.g. an exome capture BED): on-target rate and on-target mean coverage from
232
+ the `bedgraph_mapq0` coverage track. Omit `--targets` for genome-wide coverage
233
+ only. The report is target-agnostic; the exome case is simply "targets given".
234
+ - **Consensus tool performance** — when a `--consensus-log` is supplied, the
235
+ counters parsed from the consensus tool stdout log.
236
+
237
+ All inputs are **local files** — the report does no S3/DB access.
238
+
239
+ ## Usage
240
+
241
+ ```bash
242
+ # Single sample, with an exome targets BED (Quotient case)
243
+ consensus_report \
244
+ --name Z0315 \
245
+ --cram Z0315.cram --crai Z0315.cram.crai \
246
+ --sorter-stats-csv Z0315.csv --sorter-stats-json Z0315.json \
247
+ --bedgraph Z0315_0.bedGraph.gz --consensus-log Z0315.consensus.stdout.log \
248
+ --reference /data/Runs/genomes/hg38/ref_gen/Homo_sapiens_assembly38.fasta \
249
+ --targets Twist_Alliance_Clinical_Research_Exome_hg38.bed \
250
+ --output report.html
251
+
252
+ # Multiple samples: one repeatable --sample key=value block per sample
253
+ consensus_report \
254
+ --sample name=Z0315 cram=Z0315.cram sorter_stats_csv=Z0315.csv \
255
+ sorter_stats_json=Z0315.json bedgraph=Z0315_0.bedGraph.gz \
256
+ --sample name=Z0316 cram=Z0316.cram sorter_stats_csv=Z0316.csv \
257
+ sorter_stats_json=Z0316.json bedgraph=Z0316_0.bedGraph.gz \
258
+ --reference ref.fasta --targets exome.bed --output run_report.html
259
+
260
+ # ReadFuserAlignSort three-way comparison: input vs singletons vs consensus CRAM.
261
+ # Only the consensus CRAM carries rn/nf/nr, so it is the only one given a cram=
262
+ # (the others contribute alignment metrics alone, and need no localised CRAM).
263
+ # --no-summary drops the median-across-samples table, meaningless across these three.
264
+ consensus_report \
265
+ --sample name=Z0315.input sorter_stats_csv=input.csv sorter_stats_json=input.json \
266
+ --sample name=Z0315.singletons sorter_stats_csv=sing.csv sorter_stats_json=sing.json \
267
+ --sample name=Z0315.consensus cram=cons.cram crai=cons.cram.crai \
268
+ sorter_stats_csv=cons.csv sorter_stats_json=cons.json \
269
+ consensus_log=Z0315.consensus.stdout.log \
270
+ --reference ref.fasta --no-summary --output comparison.html
271
+
272
+ # b37 reference: scan chromosome 20 under its b37 name
273
+ consensus_report ... --duplex-chrom 20 --output report.html
274
+ ```
275
+
276
+ Sample rows appear in the order the `--sample` blocks are given, so the
277
+ input → singletons → consensus reading order above is preserved in the table.
278
+
279
+ Outputs (alongside `--output`):
280
+
281
+ - `<output>.html` — the self-contained HTML report.
282
+ - `<output>_per_sample.csv` — full per-sample metrics table (provenance).
283
+ - `<output>_manifest.csv` — resolved input paths (provenance).
284
+
285
+ ## Modules
286
+
287
+ | Module | Role |
288
+ |--------|------|
289
+ | `mark_duplicates_mi.py` | Duplicate families (`MI`/`DS`) and the `CS` cross-strand duplex tag; whole-file and sharded writers. |
290
+ | `mark_duplex_consensus.py` | Streams a sorted consensus CRAM and writes `dx` on the reads of duplex CS families. |
291
+ | `duplex_metrics.py` | Parse `rn`/`nf`/`nr`, classify families, family size + coverage per category (with an `MI`-tag fallback). |
292
+ | `on_target.py` | Genome-wide and optional on-target coverage from a bedGraph + targets BED. |
293
+ | `consensus_log.py` | Parse the consensus tool stdout log for performance counters. |
294
+ | `pair_consensus_reads.py` | Pairs the strand-consensus reads of a pair into unaligned R1/R2 records for UA. |
295
+ | `unpair_rename_by_strand.py` | Unpairs reads, appends the strand digit to QNAME/`MI`, optionally writes the pair orientation tag. |
296
+ | `set_consensus_strand.py` | Sets the consensus reads' reverse flag from the `nf`/`nr` majority. |
297
+ | `consensus_report.py` | CLI orchestration: read local inputs, compute metrics, write the HTML report + CSVs. |
298
+
299
+ ## Requirements
300
+
301
+ - `bedtools` and `samtools` on `PATH` (used for BED handling and CRAM decoding).
302
+ - The reference FASTA matching the run's `reference_genome`.
@@ -0,0 +1,289 @@
1
+ # ugbio_consensus
2
+
3
+ Duplicate marking and performance & duplex reports for the **ReadFuserAlignSort**
4
+ (consensus tool) pipeline at Ultima Genomics.
5
+
6
+ | Entry point | Runs | Role |
7
+ |-------------|------|------|
8
+ | `mark_duplicates_mi` | *before* `read_fuser` | Assign `MI`/`DS` duplicate families, and with `--use-umi` the `CS` cross-strand duplex tag. |
9
+ | `mark_duplex_consensus` | *after* the consensus is sorted | Write `dx=1` on the consensus reads of CS families whose `po` shows both orientations (F1R2 and F2R1). |
10
+ | `pair_consensus_reads` | *after* `read_fuser`, before alignment | Re-pair the two strand-consensus reads of each pair (shared name, R1/R2 flags) so UA 4.x aligns them as a pair. |
11
+ | `consensus_report` | *after* the pipeline | Per-sample HTML report of alignment, duplication, coverage and duplex family metrics. |
12
+
13
+ ## mark_duplicates_mi
14
+
15
+ Marks duplicate families on a coordinate-sorted BAM/CRAM and writes, on every
16
+ record of a family:
17
+
18
+ ```
19
+ MI:Z:<run-id>-<10-digit family id> the duplicate family (molecular identifier)
20
+ DS:i:<family size> number of pairs in that family
21
+ CS:Z:<run-id>-<10-digit family id> --use-umi only: the cross-strand duplex group
22
+ ```
23
+
24
+ `--use-umi` adds `(R1.u5, R2.u3)` to the dedup key alongside the position, so the
25
+ two strands of one duplex molecule land in **separate** `MI` families — which is
26
+ what we want, since each strand is consensus-called on its own. The cost is that
27
+ the duplex relationship is erased: `read_fuser` groups strictly on `MI`, so
28
+ `duplex_consensus` would be 0 by construction. `CS` restores the link.
29
+
30
+ Two clusters are cross-strand partners iff they share the positional key, their
31
+ UMI pairs are exact mutual reverses, **and** they sit on opposite strands. That
32
+ last term is read off the FLAG (`read.is_reverse`), not off which mate starts
33
+ further left: on a real library the two disagree for 10.5 % of pairs, because
34
+ fragments shorter than the read dovetail.
35
+
36
+ `CS` is **total** — a cluster with no partner gets `CS` equal to its own `MI`
37
+ rather than no tag at all. That matters downstream, because `demux --umi=CS`
38
+ treats a missing tag as the empty value, and every `CS`-less read at a position
39
+ would then collapse into one duplicate family. So `CS == MI` reads as "clustered,
40
+ but no cross-strand partner was found", and `CS != MI` as "duplex partner found".
41
+
42
+ Two opt-in options (both off by default, so the default output is unchanged) merge
43
+ clusters that are the same molecule into one `MI` family:
44
+
45
+ - `--position-tolerance BP` merges same-UMI clusters whose fragment boundaries differ
46
+ by at most `BP` (read1/read2 use asymmetric adapters, so one molecule's copies can map
47
+ 1-3 bp apart). Drifts chain transitively.
48
+ - `--cross-strand` also merges the F1R2/F2R1 strands of a duplex molecule into one `MI`,
49
+ using the same orientation-aware rule as `CS`. Boundaries must match within
50
+ `--position-tolerance`.
51
+ - `--orientation-in-key` does the opposite: the pair orientation (F1R2/F2R1/FF/RR) joins the
52
+ dedup key, so the two strands of one molecule never share an `MI`. It matters for a symmetric
53
+ UMI (`u5 == u3`), where both strands otherwise get the same key and so one `MI`. `CS` still links
54
+ the strands. On by default with `--use-umi`; `--no-orientation-in-key` turns it off, and
55
+ `--cross-strand` does so implicitly (forcing both is an error).
56
+ Without `--use-umi` it can still be forced on; `CS` is then the position family: every
57
+ orientation family at one (chrom, left_start, right_end) shares a `CS`, which over-links
58
+ independent molecules at the same place.
59
+
60
+ ```bash
61
+ # whole-file
62
+ mark_duplicates_mi input.bam output.bam --use-umi
63
+
64
+ # sharded, which is what the WDL task runs (--jobs = cpu, shards = panel intervals)
65
+ mark_duplicates_mi input.cram output.cram --use-umi \
66
+ --reference Homo_sapiens_assembly38.fasta \
67
+ --jobs 16 --regions xgen-pan-cancer-targets.hg38.bed
68
+ ```
69
+
70
+ Family-size histogram goes to stdout; `--stats-only` skips writing the output file.
71
+ Re-marking is idempotent, and re-marking **without** `--use-umi` strips any `CS`
72
+ the input carried (a stale `CS` claims a duplex link the current key does not
73
+ support, which is worse than no `CS`).
74
+
75
+ `--pair-orientation-tag po` also writes each primary read's pair orientation
76
+ (`F1R2`, `F2R1`, `FF` or `RR`) to that tag. The mates of this library carry no
77
+ mate fields (no `RNEXT`, no `0x20`), so orientation only exists where the two
78
+ are paired by name, which this tool does anyway for `MI`; doing it here costs
79
+ no extra pass. A read whose mate is absent or on another contig, or in sharded
80
+ mode further than `--pad` away, gets no tag, and a stale one is dropped. Like
81
+ `CS`, it reaches the consensus reads only if listed in `read_fuser --umi-tags`.
82
+
83
+ ### Downstream contract (BIOIN-3068)
84
+
85
+ `CS` only reaches the consensus reads if every stage is told to carry it:
86
+
87
+ 1. `read_fuser --umi-tags u3,u5,CS` — the default list is `u5,u3`, so **without
88
+ this `CS` is silently dropped** and the run still completes green.
89
+ 2. `sorter_params`: `umi_tag: "CS"` and `mark_duplicates_ends_read_uncertainty: 50`.
90
+ `umi_tag` must be `CS` **alone** — `read_fuser` copies `u5`/`u3` onto each
91
+ consensus read from its own strand's original read, and the two strands carry
92
+ them swapped, so adding them to the key splits exactly the pair `CS` exists to
93
+ join. The wide 50 bp window is needed because the two strands' consensus reads
94
+ routinely end ~27 bp apart, and it is safe only because `CS` is in the key: no
95
+ `(CS, alignment strand)` cell holds more than 2 reads, so there is nothing to
96
+ over-merge. Measured on 36,591 consensus reads: default recovers 424 of 3,423
97
+ true pairs (12.4 %), `ends_read_uncertainty: 50` recovers 3,307 (96.6 %),
98
+ precision 100 % both ways.
99
+ 3. For SRSNV/DeepSRSNV, add `"CS:Z"` to `cram_tags_to_copy`.
100
+
101
+ > ⚠️ This module is verified byte-for-byte against a 12,670,749-record baseline
102
+ > (`MI`/`DS` identical to the pre-`CS` output, 55,884 `CS` links, 0 gained / 0
103
+ > lost). The one deliberate deviation is `--regions`: a read that starts upstream of an
104
+ > interval and reaches into it is now emitted (as `samtools view -L` would), which adds
105
+ > ~30 of 12.67 M records versus that baseline. Do not refactor it — including "just" reordering the `set_tag` calls,
106
+ > which fixes the emitted aux order as `MI, DS, CS` — without re-running
107
+ > `/data/Runs/BIOIN-3068/cmp_stream.py` and `cs_links.py`.
108
+
109
+ ## mark_duplex_consensus
110
+
111
+ Writes `dx:i:1` on the consensus reads of duplex families. `DS` cannot say this: the sorter gives
112
+ `DS=2` to two consensus reads of one orientation as readily as to the two strands of a duplex
113
+ molecule. `dx` reads the orientation instead: a `CS` family is duplex when its consensus reads
114
+ include both `po=F1R2` and `po=F2R1`, and then every read of the family gets `dx=1`.
115
+
116
+ ```bash
117
+ mark_duplex_consensus consensus.cram consensus.dx.cram -T Homo_sapiens_assembly38.fasta --threads 8
118
+ ```
119
+
120
+ The input is coordinate-sorted and is streamed with a window of `--pad` bp (default 2000); a family
121
+ whose orientations start further apart than that is not seen as duplex. Reads of other families
122
+ get no `dx`, a stale `dx` is replaced, reads without `CS` and
123
+ secondary or supplementary records are left as they are. The output is indexed.
124
+
125
+ ## pair_consensus_reads
126
+
127
+ After `unpair_reads_by_strand` each strand of a pair gets its own single-end consensus read, named
128
+ `<stem>0` (forward) and `<stem>1` (reverse) with the same `CS` and `po`. Aligned alone, one of them can
129
+ land with a low MAPQ in a repeat that its mate would have resolved. This writes the two as one unaligned
130
+ pair: R1 is the forward read for `po=F1R2` and the reverse read for `F2R1` (flags 77/141), both are
131
+ named `<stem>`, and the reverse read is reverse-complemented because a mate is handed to the aligner as
132
+ sequenced. `tp`/`t0` are left alone (the read group declares them reference-oriented).
133
+
134
+ ```bash
135
+ pair_consensus_reads consensus.bam consensus.paired.bam --counters pair_counters.txt
136
+ ```
137
+
138
+ Reads without a partner, with another `po`, or whose partners disagree on `po` or `CS` are written
139
+ unchanged. The input need not be sorted: reads are spread over `--buckets` temporary files by stem.
140
+
141
+ Needs UA 4.x (`--unpaired-cost`, `--max-proper-tlen`); UA 3.x has no paired mode. A 3.x index is
142
+ read as is, with identical alignments. UA sets the strand and pair flags, so do not run the nf/nr strand
143
+ step on paired reads.
144
+
145
+ ## unpair_rename_by_strand
146
+
147
+ Turns a paired-end, duplicate-marked BAM/CRAM into a single-end one whose reads carry their strand, so the
148
+ consensus tool builds every family per strand. Each primary read gets `0` (forward) or `1` (reverse) appended
149
+ to its name and to `MI`; the pair flags and mate fields are cleared. `CS` is left as it is, since it is the
150
+ only link between the two strands of a molecule. Supplementary and secondary records are written unchanged.
151
+
152
+ ```bash
153
+ unpair_rename_by_strand in.bam out.bam -T ref.fa --threads 8 --pair-orientation-tag po \
154
+ --counters unpair_rename_counters.txt
155
+ ```
156
+
157
+ With `--pair-orientation-tag` the orientation of each pair (`F1R2`, `F2R1`, `FF`, `RR`) is written first, by
158
+ pairing the mates on name within `--pair-window` bases of the sorted input; reads without a mate in reach get
159
+ no tag. The file is cut into `--shard-size` windows that run in parallel, and the output does not depend on the
160
+ window size. The input needs an index (built when missing).
161
+
162
+ ## set_consensus_strand
163
+
164
+ read_fuser emits every consensus read reference-forward. This sets 0x10 from the `nf`/`nr` majority (`nr > nf`
165
+ is reverse, a tie is forward) and changes nothing else. Supplementary, secondary and paired records and reads
166
+ without `nf`/`nr` are left alone.
167
+
168
+ ```bash
169
+ set_consensus_strand consensus.bam consensus.strand.bam --counters strand_flag_counters.txt --require-cs-tag
170
+ ```
171
+
172
+ Exit status is 1 if a record has no `nf`/`nr`, or, with `--require-cs-tag`, if a consensus read has no `CS`.
173
+
174
+ ## consensus_report
175
+ The consensus step (`read_fuser`) fuses the reads of each UMI/MI family into a
176
+ single consensus read and records, on that read:
177
+
178
+ ```
179
+ rn:Z:<comma-separated list of the fused query names>
180
+ nf:i:<n_forward_strand_reads>
181
+ nr:i:<n_reverse_strand_reads>
182
+ ```
183
+
184
+ i.e. which reads were fused, and how many of them were on the forward (+) and
185
+ reverse (−) strand. From these tags the report classifies every consensus read
186
+ and measures family size **directly** (no MI re-grouping needed):
187
+
188
+ | Category | Condition | Family size |
189
+ |----------|-----------|-------------|
190
+ | both-strands **duplex** | `nf > 0` and `nr > 0` | `nf + nr` |
191
+ | **single-strand** duplicate | exactly one of `nf`/`nr` is 0 | the non-zero count |
192
+ | **singleton** / pass-through | no `rn` tag | 1 |
193
+
194
+ `nf + nr` equals the number of names in `rn`, so the two encodings agree on
195
+ family size; the report cross-checks this and warns on a mismatch.
196
+
197
+ > **A consensus read is identified by the presence of `rn`, never by a strand
198
+ > tag.** Trimmer already emits `rs:i` ("start position in input of segment ...",
199
+ > see the `@CO` lines in the input header) on the *input* reads, and that tag
200
+ > survives onto the reads the consensus step passes through unchanged. An earlier
201
+ > read_fuser wrote the strand counts as `fs`/`rs`, which collided with it and made
202
+ > every pass-through read look like a consensus read; the counts were renamed to
203
+ > `nf`/`nr` to break the collision. Keying off `rn` is collision-proof and also
204
+ > handles the fact that `nf` is simply *absent* (not zero) on pass-through reads —
205
+ > a parquet reader would fill that absence as `0`.
206
+
207
+ ## What the report summarises (per sample)
208
+
209
+ - **Sorter QC** — alignment, duplication and coverage metrics from
210
+ `sorter_stats_csv` (post-consensus).
211
+ - **Duplex family metrics** — average MI-family size *and* covered depth for
212
+ duplex families and for single-strand duplicate families, from the `nf`/`nr` tags.
213
+ These are scanned over one chromosome (`--duplex-chrom`, default `chr20`) — a
214
+ representative sample that avoids reading the whole (very large) CRAM. When a
215
+ targets BED is given, the scan is restricted to the targeted intervals on that
216
+ chromosome.
217
+ - **On-target metrics (optional)** — when a `--targets` BED is supplied
218
+ (e.g. an exome capture BED): on-target rate and on-target mean coverage from
219
+ the `bedgraph_mapq0` coverage track. Omit `--targets` for genome-wide coverage
220
+ only. The report is target-agnostic; the exome case is simply "targets given".
221
+ - **Consensus tool performance** — when a `--consensus-log` is supplied, the
222
+ counters parsed from the consensus tool stdout log.
223
+
224
+ All inputs are **local files** — the report does no S3/DB access.
225
+
226
+ ## Usage
227
+
228
+ ```bash
229
+ # Single sample, with an exome targets BED (Quotient case)
230
+ consensus_report \
231
+ --name Z0315 \
232
+ --cram Z0315.cram --crai Z0315.cram.crai \
233
+ --sorter-stats-csv Z0315.csv --sorter-stats-json Z0315.json \
234
+ --bedgraph Z0315_0.bedGraph.gz --consensus-log Z0315.consensus.stdout.log \
235
+ --reference /data/Runs/genomes/hg38/ref_gen/Homo_sapiens_assembly38.fasta \
236
+ --targets Twist_Alliance_Clinical_Research_Exome_hg38.bed \
237
+ --output report.html
238
+
239
+ # Multiple samples: one repeatable --sample key=value block per sample
240
+ consensus_report \
241
+ --sample name=Z0315 cram=Z0315.cram sorter_stats_csv=Z0315.csv \
242
+ sorter_stats_json=Z0315.json bedgraph=Z0315_0.bedGraph.gz \
243
+ --sample name=Z0316 cram=Z0316.cram sorter_stats_csv=Z0316.csv \
244
+ sorter_stats_json=Z0316.json bedgraph=Z0316_0.bedGraph.gz \
245
+ --reference ref.fasta --targets exome.bed --output run_report.html
246
+
247
+ # ReadFuserAlignSort three-way comparison: input vs singletons vs consensus CRAM.
248
+ # Only the consensus CRAM carries rn/nf/nr, so it is the only one given a cram=
249
+ # (the others contribute alignment metrics alone, and need no localised CRAM).
250
+ # --no-summary drops the median-across-samples table, meaningless across these three.
251
+ consensus_report \
252
+ --sample name=Z0315.input sorter_stats_csv=input.csv sorter_stats_json=input.json \
253
+ --sample name=Z0315.singletons sorter_stats_csv=sing.csv sorter_stats_json=sing.json \
254
+ --sample name=Z0315.consensus cram=cons.cram crai=cons.cram.crai \
255
+ sorter_stats_csv=cons.csv sorter_stats_json=cons.json \
256
+ consensus_log=Z0315.consensus.stdout.log \
257
+ --reference ref.fasta --no-summary --output comparison.html
258
+
259
+ # b37 reference: scan chromosome 20 under its b37 name
260
+ consensus_report ... --duplex-chrom 20 --output report.html
261
+ ```
262
+
263
+ Sample rows appear in the order the `--sample` blocks are given, so the
264
+ input → singletons → consensus reading order above is preserved in the table.
265
+
266
+ Outputs (alongside `--output`):
267
+
268
+ - `<output>.html` — the self-contained HTML report.
269
+ - `<output>_per_sample.csv` — full per-sample metrics table (provenance).
270
+ - `<output>_manifest.csv` — resolved input paths (provenance).
271
+
272
+ ## Modules
273
+
274
+ | Module | Role |
275
+ |--------|------|
276
+ | `mark_duplicates_mi.py` | Duplicate families (`MI`/`DS`) and the `CS` cross-strand duplex tag; whole-file and sharded writers. |
277
+ | `mark_duplex_consensus.py` | Streams a sorted consensus CRAM and writes `dx` on the reads of duplex CS families. |
278
+ | `duplex_metrics.py` | Parse `rn`/`nf`/`nr`, classify families, family size + coverage per category (with an `MI`-tag fallback). |
279
+ | `on_target.py` | Genome-wide and optional on-target coverage from a bedGraph + targets BED. |
280
+ | `consensus_log.py` | Parse the consensus tool stdout log for performance counters. |
281
+ | `pair_consensus_reads.py` | Pairs the strand-consensus reads of a pair into unaligned R1/R2 records for UA. |
282
+ | `unpair_rename_by_strand.py` | Unpairs reads, appends the strand digit to QNAME/`MI`, optionally writes the pair orientation tag. |
283
+ | `set_consensus_strand.py` | Sets the consensus reads' reverse flag from the `nf`/`nr` majority. |
284
+ | `consensus_report.py` | CLI orchestration: read local inputs, compute metrics, write the HTML report + CSVs. |
285
+
286
+ ## Requirements
287
+
288
+ - `bedtools` and `samtools` on `PATH` (used for BED handling and CRAM decoding).
289
+ - The reference FASTA matching the run's `reference_genome`.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "ugbio_consensus"
3
+ version = "1.34.0"
4
+ requires-python = ">=3.11"
5
+ description = "Ultima Genomics consensus (ReadFuserAlignSort) duplicate marking, performance & duplex reports"
6
+ authors = [
7
+ { name = "Gat Krieger", email = "gat.krieger@ultimagen.com" },
8
+ { name = "Doron Shem-Tov", email = "doron.shemtov@ultimagen.com" },
9
+ ]
10
+ readme = "README.consensus.md"
11
+ dependencies = [
12
+ "ugbio_core[vcfbed]",
13
+ "ugbio_cloud_utils",
14
+ "pysam>=0.22.0",
15
+ "boto3",
16
+ ]
17
+
18
+ [project.license]
19
+ text = "Apache-2.0"
20
+
21
+ [project.scripts]
22
+ run_tests = "pytest:main"
23
+ consensus_report = "ugbio_consensus.consensus_report:main"
24
+ mark_duplicates_mi = "ugbio_consensus.mark_duplicates_mi:main"
25
+ mark_duplex_consensus = "ugbio_consensus.mark_duplex_consensus:main"
26
+ pair_consensus_reads = "ugbio_consensus.pair_consensus_reads:main"
27
+ set_consensus_strand = "ugbio_consensus.set_consensus_strand:main"
28
+ unpair_rename_by_strand = "ugbio_consensus.unpair_rename_by_strand:main"
29
+
30
+ [tool.uv.sources.ugbio_core]
31
+ workspace = true
32
+
33
+ [tool.uv.sources.ugbio_cloud_utils]
34
+ workspace = true
35
+
36
+ [build-system]
37
+ requires = [
38
+ "setuptools>=61.0",
39
+ ]
40
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes