EmmaScharfmann's picture
download
raw
18.4 kB
#!/usr/bin/env python3
# -----------------------------------------------------------------------------
# Papers & Findings submission validator — MecCog / APOE4 challenge
# Author: Abigail Djossou
# Date: 2026-07-03
# -----------------------------------------------------------------------------
"""
Validator for MecCog "papers and findings" submission spreadsheets.
Based on the official spreadsheet description. Checks structure and per-column rules, and prints
a clear report telling the submitter exactly what to fix if the sheet is not valid.
Usage:
python validate_submission.py submission.xlsx
python validate_submission.py submission.xlsx --strict # warnings -> errors
The spreadsheet layout (official):
Row 1 : column labels (must all be present, correct columns)
Row 2 : hypothesis being considered (must be present)
Row 3+ : blocks, one per paper/source. Each block is:
- a PAPER row : DOI, source type, PubMed ID, ID = P1, P2, ...
- one FINDING row per finding : ID = P1.F1, P1.F2, ...
Official columns:
B DOI | C Paper/source type | D PubMed ID | E Paper/Finding ID |
F Finding description | G Finding quote | H Finding summary |
I Finding relevance (0..1) | J Experimental system | K Data location |
L Effect size (int%) | M P value (<1.0) | N Sample size (int>0)
Messages are written for challenge participants: they say which cell is wrong
and what the expected format is, without referring to internal templates.
"""
import argparse
import re
import sys
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter, column_index_from_string
# ---- official spec ------------------------------------------------------
ALLOWED_SOURCE_TYPES = {
"pubmed published", "pubmed preprint", "web article", "database", "other",
}
# canonical field -> list of header aliases (lowercased, stripped) we accept
FIELD_ALIASES = {
"doi": ["doi"],
"source_type": ["paper/source type", "paper type", "source type", "paper/source", "type"],
"pmid": ["pubmed id", "pmid", "pubmed"],
"id": ["paper or source id", "paper/finding id", "finding id", "id", "code"],
"finding_desc": ["finding description", "findings", "finding"],
"quote": ["finding quote", "quote"],
"summary": ["finding summary", "summary"],
"relevance": ["finding relevance", "relevance"],
"exp_system": ["experimental system", "exp system"],
"data_location": ["data location", "data source", "data location in the source", "location"],
"effect_size": ["effect size"],
"p_value": ["p value", "p-value", "pvalue"],
"sample_size": ["sample size", "n"],
}
# official column letters (fallback if header mapping fails)
OFFICIAL_COLS = {
"doi": "B", "source_type": "C", "pmid": "D", "id": "E",
"finding_desc": "F", "quote": "G", "summary": "H", "relevance": "I",
"exp_system": "J", "data_location": "K", "effect_size": "L",
"p_value": "M", "sample_size": "N",
}
# fields required on a FINDING row
FINDING_REQUIRED = ["finding_desc", "relevance", "exp_system", "data_location"]
class Report:
def __init__(self):
self.errors = []
self.warnings = []
def err(self, row, col, msg):
self.errors.append((row, col, msg))
def warn(self, row, col, msg):
self.warnings.append((row, col, msg))
def dump(self, strict=False):
errs = list(self.errors)
warns = list(self.warnings)
if strict:
errs += warns
warns = []
print(f"\n{'='*66}")
if not errs and not warns:
print("SUBMISSION VALID — no problems found.")
print('='*66)
print("\nYour spreadsheet passed all checks. You can submit it.\n")
return True
status = "NOT VALID" if errs else "VALID (with suggestions)"
print(f"SUBMISSION {status} — "
f"{len(errs)} thing(s) to fix, {len(warns)} suggestion(s)")
print('='*66)
if errs:
print("\nMUST FIX (the submission will be rejected until these are corrected):")
for row, col, msg in sorted(errs, key=lambda x: (x[0] or 0, x[1] or "")):
loc = f"row {row}" + (f", column {col}" if col else "")
print(f" • [{loc}] {msg}")
if warns:
print("\nPLEASE CHECK (allowed, but worth reviewing):")
for row, col, msg in sorted(warns, key=lambda x: (x[0] or 0, x[1] or "")):
loc = f"row {row}" + (f", column {col}" if col else "")
print(f" • [{loc}] {msg}")
print()
return len(errs) == 0
def norm(s):
return re.sub(r"\s+", " ", str(s).strip().lower()) if s is not None else ""
def map_columns(ws, rpt):
"""Map canonical field -> column letter, by header name, falling back to
official letters. Returns (colmap, header_ok)."""
header_row = 1
headers = {}
headers_raw = {}
for c in range(1, ws.max_column + 1):
v = ws.cell(row=header_row, column=c).value
if v is not None and str(v).strip():
letter = get_column_letter(c)
headers[letter] = norm(v)
headers_raw[letter] = str(v).strip()
colmap = {}
matched_by_header = {}
used_headers = False
matched_letters = set()
for field, aliases in FIELD_ALIASES.items():
found = None
for letter, htext in headers.items():
if htext in aliases:
found = letter
break
if found:
colmap[field] = found
matched_by_header[field] = True
matched_letters.add(found)
used_headers = True
else:
colmap[field] = OFFICIAL_COLS.get(field)
matched_by_header[field] = False
# ---- header conformance check (reported up front) ----
header_ok = True
OFFICIAL_LABELS = {
"doi": "DOI", "source_type": "Paper/source type", "pmid": "PubMed ID",
"id": "Paper/Finding ID", "finding_desc": "Finding description",
"quote": "Finding quote", "summary": "Finding summary",
"relevance": "Finding relevance", "exp_system": "Experimental system",
"data_location": "Data location", "effect_size": "Effect size",
"p_value": "P value", "sample_size": "Sample size",
}
if not used_headers:
rpt.err(1, None, "The column headers in row 1 were not recognised. "
"Please use the official submission template so the "
"columns are: " + ", ".join(OFFICIAL_LABELS.values()) + ".")
header_ok = False
else:
missing = [OFFICIAL_LABELS[f] for f in
("doi", "source_type", "pmid", "id", "finding_desc", "quote",
"summary", "relevance", "exp_system", "data_location",
"effect_size", "p_value", "sample_size")
if not matched_by_header.get(f)]
if missing:
header_ok = False
rpt.err(1, None, "These required columns are missing from row 1: "
+ ", ".join(missing) + ". Please add them (use the official "
"submission template) so every column is present with its "
"exact heading.")
# extra columns that aren't part of the official layout -> warn, ignored
extra = []
for letter, htext in headers.items():
if letter == "A":
continue # column A carries the hypothesis (row 2), no data header
if letter not in matched_letters and htext:
extra.append((letter, htext))
for letter, htext in sorted(extra):
shown = headers_raw.get(letter, htext)
rpt.warn(1, letter, f"Column '{shown}' is not an official column "
f"and will be ignored. If this was meant to be "
f"one of the required columns, please rename it "
f"to the exact official heading.")
return colmap, header_ok
def cell(ws, row, letter):
if not letter:
return None
return ws.cell(row=row, column=column_index_from_string(letter)).value
def is_paper_id(v):
return bool(re.fullmatch(r"P\d+", str(v).strip())) if v is not None else False
def is_finding_id(v):
return bool(re.fullmatch(r"P\d+\.F\d+", str(v).strip())) if v is not None else False
def validate(path, strict=False):
rpt = Report()
wb = load_workbook(path, data_only=True)
ws = wb.active
cm, header_ok = map_columns(ws, rpt)
# ---- row 2: hypothesis present ----
hyp = None
for letter in ("A", cm.get("doi")):
pass
# hypothesis is expected in row 2, column A (or first non-empty cell)
hyp_val = ws.cell(row=2, column=1).value
if not hyp_val or not str(hyp_val).strip():
# try any cell in row 2
row2 = [ws.cell(row=2, column=c).value for c in range(1, ws.max_column + 1)]
if not any(v and str(v).strip() for v in row2):
rpt.err(2, "A", "Hypothesis being considered is missing (row 2).")
# ---- iterate blocks from row 3 ----
expected_paper_n = 1
current_paper = None # e.g. "P1"
finding_counters = {} # paper -> next finding index expected
seen_dois = {}
r = 3
max_r = ws.max_row
while r <= max_r:
idv = cell(ws, r, cm["id"])
doi = cell(ws, r, cm["doi"])
# skip fully empty rows
rowvals = [ws.cell(row=r, column=c).value for c in range(1, ws.max_column + 1)]
if not any(v is not None and str(v).strip() for v in rowvals):
r += 1
continue
if is_paper_id(idv):
# ---- PAPER row ----
pid = str(idv).strip()
num = int(pid[1:])
if num != expected_paper_n:
rpt.err(r, cm["id"], f"Paper ID '{pid}' out of sequence; "
f"expected 'P{expected_paper_n}'.")
expected_paper_n = num + 1
current_paper = pid
finding_counters[pid] = 1
# DOI required
if not doi or not str(doi).strip():
rpt.err(r, cm["doi"], f"{pid}: DOI is missing.")
else:
d = str(doi).strip().lower()
if not re.match(r"10\.\d{4,9}/\S+", d):
rpt.warn(r, cm["doi"], f"{pid}: DOI '{doi}' does not look "
f"like a standard DOI (10.xxxx/...).")
if d in seen_dois:
rpt.err(r, cm["doi"], f"{pid}: this DOI is already used by "
f"{seen_dois[d]}. Each source must have "
f"a unique DOI.")
else:
seen_dois[d] = pid
# source type
st = cell(ws, r, cm["source_type"])
if not st or not str(st).strip():
rpt.err(r, cm["source_type"], f"{pid}: Paper/source type missing.")
elif norm(st) not in ALLOWED_SOURCE_TYPES:
rpt.err(r, cm["source_type"],
f"{pid}: source type '{st}' is not allowed. Use one of: "
f"PubMed published, PubMed preprint, Web article, "
f"Database, Other.")
# PMID (required for PubMed sources; recommended generally)
pmid = cell(ws, r, cm["pmid"])
st_norm = norm(st)
if st_norm.startswith("pubmed"):
if not pmid or not str(pmid).strip():
rpt.err(r, cm["pmid"], f"{pid}: PubMed ID required for "
f"PubMed sources.")
elif not re.fullmatch(r"\d+", str(pmid).strip()):
rpt.err(r, cm["pmid"], f"{pid}: PubMed ID '{pmid}' must be "
f"digits only.")
elif is_finding_id(idv):
# ---- FINDING row ----
fid = str(idv).strip()
fpaper, findex = fid.split(".")
if current_paper is None:
rpt.err(r, cm["id"], f"Finding '{fid}' appears before any paper row.")
elif fpaper != current_paper:
rpt.err(r, cm["id"], f"Finding '{fid}' does not belong to current "
f"paper '{current_paper}'.")
else:
exp = finding_counters.get(current_paper, 1)
if int(findex[1:]) != exp:
rpt.err(r, cm["id"], f"Finding '{fid}' out of sequence; "
f"expected '{current_paper}.F{exp}'.")
finding_counters[current_paper] = int(findex[1:]) + 1
# required finding fields (must be non-empty)
READABLE = {
"finding_desc": "Finding description",
"relevance": "Finding relevance",
"exp_system": "Experimental system",
"data_location": "Data location",
}
for fld in FINDING_REQUIRED:
v = cell(ws, r, cm[fld])
if v is None or not str(v).strip():
rpt.err(r, cm[fld], f"{fid}: '{READABLE[fld]}' is required "
f"but empty. Please fill it in.")
# quote and summary must contain a value OR the text 'N/A' (never blank)
for fld, label in (("quote", "Finding quote"),
("summary", "Finding summary")):
v = cell(ws, r, cm[fld])
if v is None or not str(v).strip():
rpt.err(r, cm[fld], f"{fid}: '{label}' must not be empty. "
f"Enter the {label.lower()}, or 'N/A' if there is none.")
# relevance 0..1
rel = cell(ws, r, cm["relevance"])
if rel is not None and str(rel).strip():
try:
rv = float(rel)
if not (0.0 <= rv <= 1.0):
rpt.err(r, cm["relevance"], f"{fid}: relevance must be a "
f"number between 0 and 1 (found {rv}).")
except ValueError:
rpt.err(r, cm["relevance"], f"{fid}: relevance must be a "
f"number between 0 and 1 (found '{rel}').")
# data location precision (warn if whole-figure only)
loc = cell(ws, r, cm["data_location"])
if loc and str(loc).strip():
locs = str(loc).strip()
if re.fullmatch(r"(fig(ure)?\s*\d+)", locs, flags=re.I):
rpt.warn(r, cm["data_location"],
f"{fid}: location '{locs}' may be imprecise "
f"(panel not specified, e.g. Fig4C).")
# effect size: whole number followed by %, or N/A (must not be blank)
es = cell(ws, r, cm.get("effect_size"))
if es is None or not str(es).strip():
rpt.err(r, cm.get("effect_size"), f"{fid}: 'Effect size' must "
f"not be empty. Enter a whole number + '%' (e.g. 300%), "
f"or 'N/A' if not available/applicable.")
elif norm(es) != "n/a" and not re.fullmatch(r"\d+%", str(es).strip()):
rpt.err(r, cm.get("effect_size"),
f"{fid}: effect size '{es}' must be a whole number "
f"followed by '%' (e.g. 300%), or 'N/A'.")
# p value: number < 1.0, or N/A (must not be blank)
pv = cell(ws, r, cm.get("p_value"))
if pv is None or not str(pv).strip():
rpt.err(r, cm.get("p_value"), f"{fid}: 'P value' must not be "
f"empty. Enter a number less than 1.0, or 'N/A' if not "
f"available/applicable.")
elif norm(pv) != "n/a":
m = re.search(r"[-+]?\d*\.?\d+(e[-+]?\d+)?", str(pv), flags=re.I)
if not m:
rpt.err(r, cm.get("p_value"),
f"{fid}: p value '{pv}' must be a number less than "
f"1.0, or 'N/A'.")
else:
try:
if float(m.group()) >= 1.0:
rpt.err(r, cm.get("p_value"),
f"{fid}: p value '{pv}' must be less than 1.0.")
except ValueError:
rpt.err(r, cm.get("p_value"),
f"{fid}: p value '{pv}' must be a number less "
f"than 1.0, or 'N/A'.")
# sample size: whole number > 0, or N/A (must not be blank)
ss = cell(ws, r, cm.get("sample_size"))
if ss is None or not str(ss).strip():
rpt.err(r, cm.get("sample_size"), f"{fid}: 'Sample size' must "
f"not be empty. Enter a whole number greater than 0, or "
f"'N/A' if not applicable.")
elif norm(ss) != "n/a":
if not re.fullmatch(r"\d+", str(ss).strip()) or int(ss) <= 0:
rpt.err(r, cm.get("sample_size"),
f"{fid}: sample size '{ss}' must be a whole number "
f"greater than 0, or 'N/A'.")
else:
# a non-empty row whose ID cell isn't a valid P#/P#.F# code
if idv is not None and str(idv).strip():
rpt.err(r, cm["id"], f"ID '{idv}' is neither a paper ID (P1) "
f"nor a finding ID (P1.F1).")
else:
rpt.warn(r, cm["id"], "Non-empty row with no ID in the ID column.")
r += 1
if expected_paper_n == 1:
rpt.err(None, None, "No paper/source blocks found (expected P1, P2, ...).")
return rpt.dump(strict=strict)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("file", help="submission .xlsx to validate")
ap.add_argument("--strict", action="store_true",
help="treat warnings as errors")
args = ap.parse_args()
ok = validate(args.file, strict=args.strict)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
18.4 kB
·
Xet hash:
6220227918bb7271ae8abe44adf55ab1c66293fcb4c8c0fd25fc3853edfbf566

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.