Skip to content

Commit 6d6ae9d

Browse files
committed
feat: add new strand conflict rule if both '+' and '-' appear OR any unknown ('.' or '?') appears together with any known ('+' or '-')
1 parent 1452938 commit 6d6ae9d

1 file changed

Lines changed: 52 additions & 15 deletions

File tree

src/liftclean/scripts/gff_strand_checker.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,11 @@ def main():
5555
sys.exit(1)
5656

5757
# Data structures
58-
gene_strands = defaultdict(lambda: {"+": set(), "-": set()})
59-
parent_strands = defaultdict(lambda: {"+": set(), "-": set()})
58+
# UPDATED: track unknown strands '.' and '?' too
59+
gene_strands = defaultdict(lambda: {"+": set(), "-": set(), ".": set(), "?": set()})
60+
parent_strands = defaultdict(
61+
lambda: {"+": set(), "-": set(), ".": set(), "?": set()}
62+
)
6063
gene_to_transcripts = defaultdict(list) # gene_id -> [transcript line nums]
6164
transcript_children = defaultdict(
6265
list
@@ -140,15 +143,17 @@ def main():
140143
if fid and pid and ftype not in ["exon", "CDS"]:
141144
transcript_to_gene[fid] = pid
142145
gene_to_transcripts[pid].append(line_num)
143-
if strand in ["+", "-"]:
146+
# UPDATED: track '.', '?' too
147+
if strand in ["+", "-", ".", "?"]:
144148
gene_strands[pid][strand].add(line_num)
145149

146150
# Classify exon/CDS children per transcript
147151
if pid and ftype in ["exon", "CDS"]:
148152
transcript_children[pid].append(line_num)
149153

150154
# Track strand of any child under its parent
151-
if pid and strand in ["+", "-"]:
155+
# UPDATED: track '.', '?' too
156+
if pid and strand in ["+", "-", ".", "?"]:
152157
parent_strands[pid][strand].add(line_num)
153158

154159
# Helpers
@@ -174,6 +179,17 @@ def is_exempt_parent(pid):
174179
# - the parent ID is in exempt_parent_ids (any of its occurrences had exception=trans-splicing)
175180
return args.allow_trans_splicing and pid in exempt_parent_ids
176181

182+
# NEW: strand conflict rule:
183+
# conflict if:
184+
# - both '+' and '-' appear; OR
185+
# - any unknown ('.' or '?') appears together with any known ('+' or '-')
186+
def has_strand_conflict(strands_dict):
187+
has_plus = bool(strands_dict.get("+"))
188+
has_minus = bool(strands_dict.get("-"))
189+
has_known = has_plus or has_minus
190+
has_unk = bool(strands_dict.get(".")) or bool(strands_dict.get("?"))
191+
return (has_plus and has_minus) or (has_known and has_unk)
192+
177193
# 0) Duplicate ID detection & cascading removals (only if enabled, and only for PARENT features)
178194
if args.check_dup_ids:
179195
# Only consider IDs that have children (i.e., appear as a Parent= somewhere)
@@ -246,17 +262,26 @@ def is_exempt_parent(pid):
246262
parts = feature_lines[line_num - 1][1].split("\t")
247263
if len(parts) < 3 or parts[2] not in ("gene", "ncRNA_gene"):
248264
continue # only check for real gene entries
249-
if strands["+"] and strands["-"]:
250-
print(f"Error: Gene '{gene_id}' has transcripts on both + and - strands.")
265+
266+
# UPDATED: use has_strand_conflict (includes unknown '.'/'?')
267+
if has_strand_conflict(strands):
268+
print(
269+
f"Error: Gene '{gene_id}' has transcripts with conflicting strands (mix of +/-, or unknown with known)."
270+
)
251271
for ln in sorted(strands["+"]):
252272
print(f" + (line {ln}): {feature_lines[ln - 1][1]}")
253273
for ln in sorted(strands["-"]):
254274
print(f" - (line {ln}): {feature_lines[ln - 1][1]}")
275+
for ln in sorted(strands["."]):
276+
print(f" . (line {ln}): {feature_lines[ln - 1][1]}")
277+
for ln in sorted(strands["?"]):
278+
print(f" ? (line {ln}): {feature_lines[ln - 1][1]}")
255279
print()
256280
if args.remove:
257-
lines_to_remove.update(strands["+"] | strands["-"])
281+
# UPDATED: remove all strand buckets involved
282+
lines_to_remove.update(strands["+"] | strands["-"] | strands["."] | strands["?"])
258283
affected_genes.add(gene_id)
259-
for ln in strands["+"] | strands["-"]:
284+
for ln in (strands["+"] | strands["-"] | strands["."] | strands["?"]):
260285
attrs = feature_lines[ln - 1][1].split("\t")[8]
261286
tm = re.search(r"ID=([^;]+)", attrs)
262287
if tm:
@@ -284,7 +309,8 @@ def is_exempt_parent(pid):
284309
if parts[2] not in ("gene", "ncRNA_gene"):
285310
continue
286311
strand = parts[6]
287-
if strand in ["+", "-"]:
312+
# UPDATED: include '.' and '?'
313+
if strand in ["+", "-", ".", "?"]:
288314
gene_occ_strands.append(strand)
289315

290316
if not gene_occ_strands:
@@ -299,7 +325,8 @@ def is_exempt_parent(pid):
299325
ctype, cstrand = cparts[2], cparts[6]
300326
if ctype not in ("exon", "CDS"):
301327
continue
302-
if cstrand not in ["+", "-"]:
328+
# UPDATED: include '.' and '?'
329+
if cstrand not in ["+", "-", ".", "?"]:
303330
continue
304331

305332
# If ANY gene occurrence has a different strand than this child, it’s a conflict
@@ -309,13 +336,13 @@ def is_exempt_parent(pid):
309336
if bad_children:
310337
parent_strand_summary = ",".join(sorted(set(gene_occ_strands)))
311338
print(
312-
f"Error: Gene '{gene_id}' (strand(s) {parent_strand_summary}) has direct child features on the opposite strand."
339+
f"Error: Gene '{gene_id}' (strand(s) {parent_strand_summary}) has direct child features on conflicting strands."
313340
)
314341
for ln in sorted(bad_children):
315342
print(f" child (line {ln}): {feature_lines[ln - 1][1]}")
316343
print()
317344
if args.remove:
318-
# Remove those opposite-strand children and any of their descendants
345+
# Remove those conflicting children and any of their descendants
319346
stack = list(bad_children)
320347
while stack:
321348
cur = stack.pop()
@@ -345,15 +372,24 @@ def is_exempt_parent(pid):
345372
if parent_type in ("gene", "ncRNA_gene"):
346373
continue # already handled above
347374

348-
if strands["+"] and strands["-"]:
349-
print(f"Error: Parent '{parent_id}' has child features on both strands.")
375+
# UPDATED: use has_strand_conflict to include '.'/'?' mixing with +/- as conflict
376+
if has_strand_conflict(strands):
377+
print(
378+
f"Error: Parent '{parent_id}' has child features with conflicting strands (mix of +/-, or unknown with known)."
379+
)
350380
for ln in sorted(strands["+"]):
351381
print(f" + (line {ln}): {feature_lines[ln - 1][1]}")
352382
for ln in sorted(strands["-"]):
353383
print(f" - (line {ln}): {feature_lines[ln - 1][1]}")
384+
for ln in sorted(strands["."]):
385+
print(f" . (line {ln}): {feature_lines[ln - 1][1]}")
386+
for ln in sorted(strands["?"]):
387+
print(f" ? (line {ln}): {feature_lines[ln - 1][1]}")
354388
print()
355389
if args.remove:
356-
lines_to_remove.update(strands["+"] | strands["-"])
390+
lines_to_remove.update(
391+
strands["+"] | strands["-"] | strands["."] | strands["?"]
392+
)
357393
# Also drop the parent itself to avoid orphan children mix
358394
if parent_ln:
359395
lines_to_remove.add(parent_ln)
@@ -490,3 +526,4 @@ def is_exempt_parent(pid):
490526

491527
if __name__ == "__main__":
492528
main()
529+

0 commit comments

Comments
 (0)