Skip to content

Commit e48266e

Browse files
committed
refactor(mpa): overhaul kreport parsing logic for robust hierarchy tracking
1 parent e15d1ef commit e48266e

1 file changed

Lines changed: 82 additions & 60 deletions

File tree

krakenparser/mpa/transform2mpa.py

Lines changed: 82 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,51 +9,65 @@
99

1010
from krakenparser.utils import ensure_output_dir
1111

12-
# Maps Kraken2 single-letter rank codes to MPA prefixes
13-
_RANK_PREFIX = {
14-
"D": "d",
15-
"K": "k",
16-
"P": "p",
17-
"C": "c",
18-
"O": "o",
19-
"F": "f",
20-
"G": "g",
21-
"S": "s",
22-
}
23-
2412
_log = logging.getLogger(__name__)
2513

14+
_MAIN_LVLS = {"R", "K", "D", "P", "C", "O", "F", "G", "S"}
15+
2616

27-
def _parse_line(line: str):
17+
def _parse_line(line: str, remove_spaces: bool = False) -> list:
2818
"""
2919
Parse one Kraken2 report line.
3020
31-
Standard format (6 columns):
32-
pct cum_reads direct_reads rank taxid name(indented)
33-
34-
Returns (name, depth, rank, cum_reads, pct) or None on malformed input.
21+
Returns [name, level_num, level_type, all_reads, percents]
22+
or empty list on malformed input.
3523
"""
3624
parts = line.rstrip("\n").split("\t")
37-
if len(parts) < 5:
38-
return None
25+
if len(parts) < 4:
26+
return []
3927
try:
40-
pct = float(parts[0])
41-
cum_reads = int(parts[1])
28+
int(parts[1])
4229
except ValueError:
43-
return None
30+
return []
4431

45-
rank = parts[3].strip()
46-
name_field = parts[-1] # always the last column regardless of format variant
32+
try:
33+
percents = float(parts[0])
34+
except ValueError:
35+
return []
36+
all_reads = int(parts[1])
37+
38+
try:
39+
int(parts[-3])
40+
level_type = parts[-2].strip()
41+
map_kuniq = {
42+
"species": "S",
43+
"genus": "G",
44+
"family": "F",
45+
"order": "O",
46+
"class": "C",
47+
"phylum": "P",
48+
"superkingdom": "D",
49+
"kingdom": "K",
50+
}
51+
if level_type not in map_kuniq:
52+
level_type = "-"
53+
else:
54+
level_type = map_kuniq[level_type]
55+
except ValueError:
56+
level_type = parts[-3].strip()
4757

48-
depth = 0
49-
for ch in name_field:
58+
name = parts[-1]
59+
spaces = 0
60+
for ch in name:
5061
if ch == " ":
51-
depth += 1
62+
spaces += 1
5263
else:
5364
break
54-
name = name_field.strip()
65+
name = name.strip()
66+
if remove_spaces:
67+
name = name.replace(" ", "_")
5568

56-
return name, depth // 2, rank, cum_reads, pct
69+
level_num = spaces / 2
70+
return [name, level_num, level_type, all_reads, percents]
5771

5872

5973
def kreport_to_mpa(
@@ -67,54 +81,63 @@ def kreport_to_mpa(
6781
"""
6882
Convert a single Kraken2 report to MPA format.
6983
70-
Uses a stack to track the current taxonomic path. Each entry is
71-
(structural_depth, mpa_segment, is_standard_rank). When a node at
72-
depth d is encountered, all stack entries with depth >= d are popped
73-
before the new entry is pushed, keeping the path consistent.
84+
Tracks the current taxonomic path via curr_path and prev_lvl_num,
85+
popping the stack when moving back up the tree — exactly as the
86+
original script does.
7487
"""
7588
if not Path(report_path).is_file():
7689
raise FileNotFoundError(f"Input file not found: {report_path}")
7790
out_path = ensure_output_dir(output_path, is_file=True)
78-
# Stack entries: (structural_depth, mpa_segment, is_standard_rank)
79-
stack: list[tuple[int, str, bool]] = []
91+
92+
curr_path: list[str] = []
93+
prev_lvl_num = -1
8094

8195
with open(report_path) as r_fh, open(out_path, "w") as o_fh:
8296
if display_header:
8397
o_fh.write("#Classification\t" + os.path.basename(report_path) + "\n")
8498

8599
for line in r_fh:
86-
parsed = _parse_line(line)
87-
if parsed is None:
100+
report_vals = _parse_line(line, remove_spaces)
101+
if len(report_vals) < 5:
88102
continue
89-
name, depth, rank, cum_reads, pct = parsed
90103

91-
# Skip unclassified and root — never appear in MPA output
92-
if rank in ("U", "R"):
104+
name, level_num, level_type, all_reads, percents = report_vals
105+
106+
# Пропускаем unclassified
107+
if level_type == "U":
93108
continue
94109

95-
# Strip numeric suffix to get base rank (e.g. "S1" → "S", "G2" → "G")
96-
rank_base = rank.rstrip("0123456789")
97-
is_standard = rank_base in _RANK_PREFIX and rank == rank_base
110+
# Нормализуем тип уровня
111+
if level_type not in _MAIN_LVLS:
112+
level_type = "x"
113+
elif level_type == "K":
114+
level_type = "k"
115+
elif level_type == "D":
116+
level_type = "d"
98117

99-
if not is_standard and not include_intermediate:
100-
continue
118+
level_str = level_type.lower() + "__" + name
101119

102-
prefix = _RANK_PREFIX.get(rank_base, "x")
103-
seg_name = name.replace(" ", "_") if remove_spaces else name
104-
mpa_seg = f"{prefix}__{seg_name}"
120+
if prev_lvl_num == -1:
121+
prev_lvl_num = level_num
122+
curr_path.append(level_str)
123+
continue
105124

106-
# Trim stack to the current structural depth
107-
while stack and stack[-1][0] >= depth:
108-
stack.pop()
109-
stack.append((depth, mpa_seg, is_standard))
125+
while level_num != (prev_lvl_num + 1):
126+
prev_lvl_num -= 1
127+
curr_path.pop()
110128

111-
# Build the full MPA path; omit intermediate (x__) segments when not requested
112-
path = "|".join(
113-
seg for (_, seg, std) in stack if include_intermediate or std
114-
)
129+
if (level_type == "x" and include_intermediate) or level_type != "x":
130+
ancestors = [
131+
seg
132+
for seg in curr_path
133+
if (seg[0] != "x" or include_intermediate) and seg[0] != "r"
134+
]
135+
path = "|".join(ancestors + [level_str])
136+
value = str(all_reads) if use_reads else str(percents)
137+
o_fh.write(path + "\t" + value + "\n")
115138

116-
value = str(cum_reads) if use_reads else str(pct)
117-
o_fh.write(f"{path}\t{value}\n")
139+
curr_path.append(level_str)
140+
prev_lvl_num = level_num
118141

119142

120143
def main() -> None:
@@ -137,7 +160,6 @@ def main() -> None:
137160
dest="input_dir",
138161
help="Input directory containing Kraken2 report files (batch mode)",
139162
)
140-
141163
parser.add_argument(
142164
"-o",
143165
"--output",
@@ -212,7 +234,7 @@ def main() -> None:
212234
continue
213235
out_name = f.name.replace(".kreport", ".MPA.TXT")
214236
kreport_to_mpa(str(f), str(output_dir / out_name), **kwargs)
215-
_log.info(f"Converted to MPA successfully. Output stored in {output_dir}")
237+
_log.info("Converted to MPA successfully. Output stored in %s", output_dir)
216238
else:
217239
kreport_to_mpa(args.r_file, args.o_file, **kwargs)
218240

0 commit comments

Comments
 (0)