Spaces:
Sleeping
Sleeping
Mark Cuizon commited on
Commit ·
e400cff
1
Parent(s): cf264e0
fix: parse SP Lab vertical OCR layout for accurate soil structured data
Browse filesAdd column-major vertical Document AI parser for SP Lab reports (parameter label then one value per sample line). Fix Total P/Available P digits being skipped as column indices. Verified exact match on all 10 samples from user OCR including N.D.
- modules/upload.py +285 -8
modules/upload.py
CHANGED
|
@@ -1480,24 +1480,303 @@ def _align_soil_numbers_to_params(numbers: list, params: list, sample_id: str =
|
|
| 1480 |
return _align_numbers_to_params(numbers, params, sample_id, kind='soil')
|
| 1481 |
|
| 1482 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1483 |
def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: list) -> dict:
|
| 1484 |
"""
|
| 1485 |
Parse SP Lab samples (e.g. S218/25 or P220/25) from real OCR text.
|
| 1486 |
Never uses hardcoded values — structured data must match Raw OCR Text.
|
| 1487 |
|
| 1488 |
Handles:
|
|
|
|
| 1489 |
- Row-per-sample: S218/25 5.26 0.12 0.91 ...
|
| 1490 |
-
- Column-major / transposed (
|
| 1491 |
-
S218/25 S219/25 S220/25
|
| 1492 |
-
pH 5.26 5.10 4.88
|
| 1493 |
-
N 0.12 0.11 0.08
|
| 1494 |
-
...
|
| 1495 |
- Leading junk numbers before pH (lab serial bleeding into values)
|
| 1496 |
"""
|
| 1497 |
samples = {}
|
| 1498 |
if not text:
|
| 1499 |
return samples
|
| 1500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1501 |
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
| 1502 |
joined = ' '.join(lines)
|
| 1503 |
joined_norm = re.sub(r'[^\w\s\.\-\(\)\[\]\/<>%]', ' ', joined)
|
|
@@ -1527,9 +1806,7 @@ def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: lis
|
|
| 1527 |
is_soil = bool(params and params[0] == 'pH')
|
| 1528 |
|
| 1529 |
# ------------------------------------------------------------------
|
| 1530 |
-
# Strategy T
|
| 1531 |
-
# Sample IDs appear first (often one line), then parameter rows with
|
| 1532 |
-
# one value per sample in the same column order.
|
| 1533 |
# ------------------------------------------------------------------
|
| 1534 |
def _parse_transposed() -> dict:
|
| 1535 |
# Collect ordered unique sample IDs as they appear
|
|
|
|
| 1480 |
return _align_numbers_to_params(numbers, params, sample_id, kind='soil')
|
| 1481 |
|
| 1482 |
|
| 1483 |
+
def _parse_sp_lab_vertical_document(text: str, sample_prefix: str, params: list) -> dict:
|
| 1484 |
+
"""Parse SP Lab reports where Document AI dumps one token per line.
|
| 1485 |
+
|
| 1486 |
+
Real layout (user OCR)::
|
| 1487 |
+
|
| 1488 |
+
S218/25
|
| 1489 |
+
S219/25
|
| 1490 |
+
...
|
| 1491 |
+
1
|
| 1492 |
+
2
|
| 1493 |
+
3
|
| 1494 |
+
PH
|
| 1495 |
+
5.0
|
| 1496 |
+
4.3
|
| 1497 |
+
...
|
| 1498 |
+
Nitrogen
|
| 1499 |
+
0.10
|
| 1500 |
+
...
|
| 1501 |
+
|
| 1502 |
+
Sample IDs form columns; each parameter label is followed by one value
|
| 1503 |
+
per sample (same order). Multiple table sections are supported.
|
| 1504 |
+
"""
|
| 1505 |
+
if not text:
|
| 1506 |
+
return {}
|
| 1507 |
+
|
| 1508 |
+
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
| 1509 |
+
prefix = sample_prefix.upper()
|
| 1510 |
+
id_re = re.compile(rf'^{prefix}\d{{1,4}}\s*/\s*\d{{2}}$', re.I)
|
| 1511 |
+
id_find = re.compile(rf'\b({prefix}\d{{1,4}}\s*/\s*\d{{2}})\b', re.I)
|
| 1512 |
+
|
| 1513 |
+
def canon(sid: str) -> str:
|
| 1514 |
+
sid = re.sub(r'\s+', '', sid.strip())
|
| 1515 |
+
return sid[0].upper() + sid[1:] if sid else sid
|
| 1516 |
+
|
| 1517 |
+
# Soil / leaf parameter label matchers (order matters for specificity)
|
| 1518 |
+
if params and params[0] == 'pH':
|
| 1519 |
+
label_specs = [
|
| 1520 |
+
(re.compile(r'^pH$', re.I), 'pH'),
|
| 1521 |
+
(re.compile(r'^PH$', re.I), 'pH'),
|
| 1522 |
+
(re.compile(r'^Nitrogen$', re.I), 'N (%)'),
|
| 1523 |
+
(re.compile(r'^N\s*\(%\)$', re.I), 'N (%)'),
|
| 1524 |
+
(re.compile(r'^Organic\s*Carbon$', re.I), 'Org. C (%)'),
|
| 1525 |
+
(re.compile(r'^Org\.?\s*C', re.I), 'Org. C (%)'),
|
| 1526 |
+
(re.compile(r'^Total\s*P', re.I), 'Total P (mg/kg)'),
|
| 1527 |
+
(re.compile(r'^Available\s*P', re.I), 'Avail P (mg/kg)'),
|
| 1528 |
+
(re.compile(r'^Avail\.?\s*P', re.I), 'Avail P (mg/kg)'),
|
| 1529 |
+
(re.compile(r'^Exch\.?\s*K', re.I), 'Exch. K (meq%)'),
|
| 1530 |
+
(re.compile(r'^Exch\.?\s*Ca', re.I), 'Exch. Ca (meq%)'),
|
| 1531 |
+
(re.compile(r'^Exch\.?\s*Mg', re.I), 'Exch. Mg (meq%)'),
|
| 1532 |
+
(re.compile(r'^C\.?E\.?C\.?$', re.I), 'CEC (meq%)'),
|
| 1533 |
+
]
|
| 1534 |
+
else:
|
| 1535 |
+
label_specs = [
|
| 1536 |
+
(re.compile(r'^N(?:itrogen)?$', re.I), 'N (%)'),
|
| 1537 |
+
(re.compile(r'^P(?:hos(?:phorus)?)?$', re.I), 'P (%)'),
|
| 1538 |
+
(re.compile(r'^K(?:alium|potassium)?$', re.I), 'K (%)'),
|
| 1539 |
+
(re.compile(r'^Mg(?:agnesium)?$', re.I), 'Mg (%)'),
|
| 1540 |
+
(re.compile(r'^Ca(?:lcium)?$', re.I), 'Ca (%)'),
|
| 1541 |
+
(re.compile(r'^B(?:oron)?$', re.I), 'B (mg/kg)'),
|
| 1542 |
+
(re.compile(r'^Cu(?:pper)?$', re.I), 'Cu (mg/kg)'),
|
| 1543 |
+
(re.compile(r'^Zn(?:inc)?$', re.I), 'Zn (mg/kg)'),
|
| 1544 |
+
]
|
| 1545 |
+
|
| 1546 |
+
skip_labels = re.compile(
|
| 1547 |
+
r'^(Exchangeable|Parameter|Lab\s*No.*|Analysis|Date|Page|Serial|'
|
| 1548 |
+
r'Your\s*Ref|TEST\s*REPORT|SP\s*LAB|Sarawak|Non-?Detected|'
|
| 1549 |
+
r'%|mg/kg|meq%|meq/?%?)$',
|
| 1550 |
+
re.I,
|
| 1551 |
+
)
|
| 1552 |
+
|
| 1553 |
+
def match_param_label(line: str):
|
| 1554 |
+
for preg, key in label_specs:
|
| 1555 |
+
if preg.match(line.strip()):
|
| 1556 |
+
return key
|
| 1557 |
+
return None
|
| 1558 |
+
|
| 1559 |
+
def is_pure_index(line: str) -> bool:
|
| 1560 |
+
"""Column index rows like 1,2,3...10 that appear under sample IDs."""
|
| 1561 |
+
return bool(re.fullmatch(r'\d{1,2}', line.strip()))
|
| 1562 |
+
|
| 1563 |
+
def is_value_token(line: str) -> bool:
|
| 1564 |
+
s = line.strip()
|
| 1565 |
+
if not s:
|
| 1566 |
+
return False
|
| 1567 |
+
if id_re.match(s) or id_find.search(s):
|
| 1568 |
+
return False
|
| 1569 |
+
if match_param_label(s):
|
| 1570 |
+
return False
|
| 1571 |
+
if skip_labels.match(s):
|
| 1572 |
+
return False
|
| 1573 |
+
# N.D. / ND
|
| 1574 |
+
if re.fullmatch(r'N\.?\s*D\.?', s, re.I):
|
| 1575 |
+
return True
|
| 1576 |
+
if re.fullmatch(r'<?\d+\.?\d*', s):
|
| 1577 |
+
return True
|
| 1578 |
+
return False
|
| 1579 |
+
|
| 1580 |
+
def parse_value(line: str):
|
| 1581 |
+
s = line.strip()
|
| 1582 |
+
if re.fullmatch(r'N\.?\s*D\.?', s, re.I):
|
| 1583 |
+
return 0.0
|
| 1584 |
+
return _parse_numeric_token(s)
|
| 1585 |
+
|
| 1586 |
+
# Split document into table sections (each starts near sample IDs after "Lab No" / "Parameter")
|
| 1587 |
+
sections = []
|
| 1588 |
+
current = []
|
| 1589 |
+
for line in lines:
|
| 1590 |
+
if re.search(r'Lab\s*No', line, re.I) and current:
|
| 1591 |
+
sections.append(current)
|
| 1592 |
+
current = [line]
|
| 1593 |
+
else:
|
| 1594 |
+
current.append(line)
|
| 1595 |
+
if current:
|
| 1596 |
+
sections.append(current)
|
| 1597 |
+
|
| 1598 |
+
# If no clear section markers, treat whole doc as one section
|
| 1599 |
+
if len(sections) <= 1:
|
| 1600 |
+
# Also try splitting when a new block of sample IDs appears after parameters
|
| 1601 |
+
sections = [lines]
|
| 1602 |
+
|
| 1603 |
+
all_samples = {}
|
| 1604 |
+
|
| 1605 |
+
def parse_section(sec_lines: list) -> dict:
|
| 1606 |
+
# Collect sample IDs in order of appearance (within this section)
|
| 1607 |
+
sample_ids = []
|
| 1608 |
+
seen = set()
|
| 1609 |
+
for line in sec_lines:
|
| 1610 |
+
for m in id_find.finditer(line):
|
| 1611 |
+
sid = canon(m.group(1))
|
| 1612 |
+
if sid not in seen:
|
| 1613 |
+
seen.add(sid)
|
| 1614 |
+
sample_ids.append(sid)
|
| 1615 |
+
if len(sample_ids) < 1:
|
| 1616 |
+
return {}
|
| 1617 |
+
|
| 1618 |
+
n = len(sample_ids)
|
| 1619 |
+
# param -> list of n values
|
| 1620 |
+
param_cols = {p: None for p in params}
|
| 1621 |
+
|
| 1622 |
+
i = 0
|
| 1623 |
+
while i < len(sec_lines):
|
| 1624 |
+
line = sec_lines[i]
|
| 1625 |
+
# Skip sample id lines, indices, units, headers
|
| 1626 |
+
if id_find.search(line) and not match_param_label(line):
|
| 1627 |
+
i += 1
|
| 1628 |
+
continue
|
| 1629 |
+
if is_pure_index(line):
|
| 1630 |
+
i += 1
|
| 1631 |
+
continue
|
| 1632 |
+
if skip_labels.match(line):
|
| 1633 |
+
i += 1
|
| 1634 |
+
continue
|
| 1635 |
+
|
| 1636 |
+
pkey = match_param_label(line)
|
| 1637 |
+
if pkey and pkey in param_cols:
|
| 1638 |
+
# Collect the next `n` value tokens after this parameter label.
|
| 1639 |
+
# IMPORTANT: after a label, digits like 59 / 2 / 1 are REAL lab
|
| 1640 |
+
# values (Total P, Available P), NOT column indices 1..10.
|
| 1641 |
+
values = []
|
| 1642 |
+
j = i + 1
|
| 1643 |
+
while j < len(sec_lines) and len(values) < n:
|
| 1644 |
+
nxt = sec_lines[j]
|
| 1645 |
+
# Stop if we hit next param label
|
| 1646 |
+
if match_param_label(nxt):
|
| 1647 |
+
break
|
| 1648 |
+
# Units / section words only — do not treat digits as indices here
|
| 1649 |
+
if skip_labels.match(nxt):
|
| 1650 |
+
j += 1
|
| 1651 |
+
# If we already have values and hit a unit line, we're done
|
| 1652 |
+
if values:
|
| 1653 |
+
break
|
| 1654 |
+
continue
|
| 1655 |
+
if id_find.search(nxt):
|
| 1656 |
+
break
|
| 1657 |
+
if re.fullmatch(r'N\.?\s*D\.?', nxt, re.I) or re.fullmatch(r'<?\d+\.?\d*', nxt):
|
| 1658 |
+
val = parse_value(nxt)
|
| 1659 |
+
if val is not None:
|
| 1660 |
+
values.append(val)
|
| 1661 |
+
j += 1
|
| 1662 |
+
else:
|
| 1663 |
+
j += 1
|
| 1664 |
+
if values:
|
| 1665 |
+
break
|
| 1666 |
+
if len(values) >= n:
|
| 1667 |
+
param_cols[pkey] = values[:n]
|
| 1668 |
+
elif len(values) > 0:
|
| 1669 |
+
# Accept partial only if nearly complete (e.g. trailing missing)
|
| 1670 |
+
if len(values) >= n - 1:
|
| 1671 |
+
param_cols[pkey] = values
|
| 1672 |
+
i = j
|
| 1673 |
+
continue
|
| 1674 |
+
i += 1
|
| 1675 |
+
|
| 1676 |
+
# Require at least pH/N + a few more params filled
|
| 1677 |
+
filled = [p for p in params if param_cols.get(p)]
|
| 1678 |
+
if len(filled) < 3:
|
| 1679 |
+
return {}
|
| 1680 |
+
|
| 1681 |
+
# Normalize lengths: use min available
|
| 1682 |
+
out = {sid: {} for sid in sample_ids}
|
| 1683 |
+
for p in params:
|
| 1684 |
+
col = param_cols.get(p)
|
| 1685 |
+
if not col:
|
| 1686 |
+
continue
|
| 1687 |
+
for idx, sid in enumerate(sample_ids):
|
| 1688 |
+
if idx < len(col):
|
| 1689 |
+
out[sid][p] = col[idx]
|
| 1690 |
+
# Drop empty
|
| 1691 |
+
return {sid: data for sid, data in out.items() if data}
|
| 1692 |
+
|
| 1693 |
+
# Parse each Lab-No section separately (critical: each table has its own
|
| 1694 |
+
# sample count — do NOT parse the whole doc as one 10-column table).
|
| 1695 |
+
for sec in sections:
|
| 1696 |
+
part = parse_section(sec)
|
| 1697 |
+
for sid, data in part.items():
|
| 1698 |
+
if sid not in all_samples:
|
| 1699 |
+
all_samples[sid] = data
|
| 1700 |
+
else:
|
| 1701 |
+
# Merge params; prefer non-empty / longer
|
| 1702 |
+
for k, v in data.items():
|
| 1703 |
+
if k not in all_samples[sid]:
|
| 1704 |
+
all_samples[sid][k] = v
|
| 1705 |
+
|
| 1706 |
+
# If Lab-No split failed, try sample-id cluster split
|
| 1707 |
+
if len(all_samples) < 2:
|
| 1708 |
+
clusters = []
|
| 1709 |
+
cluster = []
|
| 1710 |
+
saw_id = False
|
| 1711 |
+
for line in lines:
|
| 1712 |
+
has_id = bool(id_find.search(line))
|
| 1713 |
+
if has_id:
|
| 1714 |
+
if saw_id is False and cluster and any(id_find.search(x) for x in cluster):
|
| 1715 |
+
# new cluster after params
|
| 1716 |
+
clusters.append(cluster)
|
| 1717 |
+
cluster = []
|
| 1718 |
+
saw_id = True
|
| 1719 |
+
cluster.append(line)
|
| 1720 |
+
else:
|
| 1721 |
+
if cluster:
|
| 1722 |
+
cluster.append(line)
|
| 1723 |
+
# reset saw_id after we leave a run of IDs and hit a param
|
| 1724 |
+
if match_param_label(line):
|
| 1725 |
+
saw_id = False
|
| 1726 |
+
if cluster:
|
| 1727 |
+
clusters.append(cluster)
|
| 1728 |
+
for cl in clusters:
|
| 1729 |
+
part = parse_section(cl)
|
| 1730 |
+
all_samples.update(part)
|
| 1731 |
+
|
| 1732 |
+
# Last resort: whole document only if still empty
|
| 1733 |
+
if not all_samples:
|
| 1734 |
+
all_samples = parse_section(lines)
|
| 1735 |
+
|
| 1736 |
+
return all_samples
|
| 1737 |
+
|
| 1738 |
+
|
| 1739 |
def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: list) -> dict:
|
| 1740 |
"""
|
| 1741 |
Parse SP Lab samples (e.g. S218/25 or P220/25) from real OCR text.
|
| 1742 |
Never uses hardcoded values — structured data must match Raw OCR Text.
|
| 1743 |
|
| 1744 |
Handles:
|
| 1745 |
+
- Vertical Document AI dumps (one token per line) — SP Lab official layout
|
| 1746 |
- Row-per-sample: S218/25 5.26 0.12 0.91 ...
|
| 1747 |
+
- Column-major / transposed (samples on one line, params as rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1748 |
- Leading junk numbers before pH (lab serial bleeding into values)
|
| 1749 |
"""
|
| 1750 |
samples = {}
|
| 1751 |
if not text:
|
| 1752 |
return samples
|
| 1753 |
|
| 1754 |
+
# ------------------------------------------------------------------
|
| 1755 |
+
# Strategy V (highest priority): vertical one-token-per-line SP Lab OCR
|
| 1756 |
+
# ------------------------------------------------------------------
|
| 1757 |
+
vertical = _parse_sp_lab_vertical_document(text, sample_prefix, params)
|
| 1758 |
+
if vertical:
|
| 1759 |
+
# Validate soil pH columns look sane before accepting
|
| 1760 |
+
if params and params[0] == 'pH':
|
| 1761 |
+
ph_ok = sum(
|
| 1762 |
+
1 for d in vertical.values()
|
| 1763 |
+
if isinstance(d.get('pH'), (int, float)) and 2.5 <= d['pH'] <= 9.5
|
| 1764 |
+
)
|
| 1765 |
+
if ph_ok >= max(1, len(vertical) // 2):
|
| 1766 |
+
return vertical
|
| 1767 |
+
else:
|
| 1768 |
+
# leaf: N in range
|
| 1769 |
+
n_ok = sum(
|
| 1770 |
+
1 for d in vertical.values()
|
| 1771 |
+
if isinstance(d.get('N (%)'), (int, float)) and 1.2 <= d['N (%)'] <= 4.0
|
| 1772 |
+
)
|
| 1773 |
+
if n_ok >= max(1, len(vertical) // 2) or len(vertical) >= 1:
|
| 1774 |
+
return vertical
|
| 1775 |
+
# even if validation weak, prefer vertical if it found many samples with many params
|
| 1776 |
+
avg_params = sum(len(d) for d in vertical.values()) / max(len(vertical), 1)
|
| 1777 |
+
if len(vertical) >= 3 and avg_params >= 5:
|
| 1778 |
+
return vertical
|
| 1779 |
+
|
| 1780 |
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
| 1781 |
joined = ' '.join(lines)
|
| 1782 |
joined_norm = re.sub(r'[^\w\s\.\-\(\)\[\]\/<>%]', ' ', joined)
|
|
|
|
| 1806 |
is_soil = bool(params and params[0] == 'pH')
|
| 1807 |
|
| 1808 |
# ------------------------------------------------------------------
|
| 1809 |
+
# Strategy T: transposed / column-major SP Lab layout (values on same line)
|
|
|
|
|
|
|
| 1810 |
# ------------------------------------------------------------------
|
| 1811 |
def _parse_transposed() -> dict:
|
| 1812 |
# Collect ordered unique sample IDs as they appear
|