Edit on GitHub

starplast.plasmodium

The Plasmodium falciparum table: the second species, in its own table rather than merged.

Instruction 39 settled the shape and this is its first parasite table beyond Toxoplasma. Nothing is merged. PF3D7_* and TGME49_* do not align, and neither does the data behind them -- piggyBac essentiality is falciparum, PlasmoGEM barcoded knockouts are berghei, relapse biology is vivax. Merged into one frame the missing values would encode which species was convenient to work on, and since missingness is a feature here the embedding would learn laboratory history and it would look like biology. Slots carry an organism, and a Pf_ slot resolves against this table.

Where it comes from

One PlasmoDB report, the same WDK service that answers six of the Toxoplasma slots. That single call is worth more here than it is there, because PlasmoDB curates into gene attributes what for Toxoplasma had to be found one paper at a time: the piggyBac saturation screen, the cross-strain SNP counts, InterPro and orthology all arrive as columns of one table rather than as five acquisitions.

The transcript-versus-gene trap

The report is served by the transcript record type, so a gene with two transcripts is two rows -- 5,791 rows for 5,720 genes. Every quantity here is per gene, so the rows are collapsed and the collapse is checked. Taking the report at face value would have silently double-weighted 71 genes.

What is deliberately not read

SignalP Peptide covers 10.5% and reads as a label rather than a score; it is kept, but as a boolean, and the absence of a call is not read as the absence of a signal peptide -- 89% of genes having no entry is a statement about what was run, not about the proteins.

TABLE = 'pf_nodes.parquet'
ORGANISM = 'Plasmodium falciparum 3D7'
COLUMNS = {'Gene ID': 'gene_id', 'Gene Type': 'gene_type', 'Product Description': 'product', 'Protein Length': 'length', 'Chromosome': 'chromosome', 'Transcript Length': 'transcript_length', '# Exons in Transcript': 'exon_count', 'Molecular Weight': 'molecular_weight', 'Isoelectric Point': 'isoelectric_point', '# TM Domains': 'n_tm', 'Ortholog count': 'ortholog_number', 'Paralog count': 'paralog_number', 'Ortholog Group': 'orthogroup', 'Total SNPs All Strains': 'snp_total_all_strains', 'NonSynonymous SNPs All Strains': 'snp_nonsynonymous', 'Synonymous SNPs All Strains': 'snp_synonymous', 'Non-Coding SNPs All Strains': 'snp_noncoding', 'SNPs with Stop Codons All Strains': 'snp_stop_codon', 'NonSyn/Syn SNP Ratio All Strains': 'snp_nonsyn_syn_ratio', 'P.falciparum 3D7 piggyBac insertion mutagenesis - mutant fitness score': 'piggybac_mfs', 'P.falciparum 3D7 piggyBac insertion mutagenesis - mutagenesis index score': 'piggybac_mis', 'Interpro ID': 'interpro_ids', 'PFam ID': 'pfam_ids', 'SignalP Peptide': 'signalp'}
NUMERIC = ('length', 'transcript_length', 'exon_count', 'molecular_weight', 'isoelectric_point', 'n_tm', 'ortholog_number', 'paralog_number', 'snp_total_all_strains', 'snp_nonsynonymous', 'snp_synonymous', 'snp_noncoding', 'snp_stop_codon', 'snp_nonsyn_syn_ratio', 'piggybac_mfs', 'piggybac_mis')
BLANK = ('N/A', 'null', '')
def build(report_path: str) -> pandas.DataFrame:

The Plasmodium table from one PlasmoDB attributes report.

def load(base: str) -> pandas.DataFrame:

The shipped Plasmodium table, or an empty frame if it has not been built.

EXPRESSION_TABLE = 'plasmodb_pf3d7_expression.tsv'
EXPRESSION = (('Su Seven Stages', 'Ring', 'expr_ring'), ('Su Seven Stages', 'Early Trophozoite', 'expr_early_trophozoite'), ('Su Seven Stages', 'Late Trophozoite', 'expr_late_trophozoite'), ('Su Seven Stages', 'Schizont', 'expr_schizont'), ('Su Seven Stages', 'Gametocyte II', 'expr_gametocyte_ii'), ('Su Seven Stages', 'Gametocyte V', 'expr_gametocyte_v'), ('Su Seven Stages', 'Ookinete', 'expr_ookinete'), ('Polysomal and steady-state', 'Polysomal ring', 'polysomal_ring'), ('Polysomal and steady-state', 'Polysomal troph', 'polysomal_trophozoite'), ('Polysomal and steady-state', 'Polysomal schiz', 'polysomal_schizont'), ('Polysomal and steady-state', 'Steady_state ring', 'steady_state_ring'), ('Polysomal and steady-state', 'Steady_state troph', 'steady_state_trophozoite'), ('Polysomal and steady-state', 'Steady_state schiz', 'steady_state_schizont'), ('Ring Ave', '', 'protein_stage_share_ring'), ('Troph Ave', '', 'protein_stage_share_trophozoite'), ('Schizont Ave', '', 'protein_stage_share_schizont'), ('sense - asexual blood stages', '', 'expr_asexual_blood'), ('antisense - asexual blood stages', '', 'antisense_asexual_blood'), ('antisense - midgut oocysts', '', 'antisense_oocyst'), ('antisense - salivary gland sporozoites', '', 'antisense_sporozoite'), ('sense - midgut oocysts', '', 'expr_oocyst'), ('sense - salivary gland sporozoites', '', 'expr_sporozoite'))
def expression(report_path: str) -> pandas.DataFrame:

Per-stage transcript abundance and the polysomal fraction, keyed by gene.

EXPORT_DIR = 'exportpred'
EXPORT_TIERS = (1, 5, 10)
def export_prediction(folder: str) -> pandas.DataFrame:

Predicted export to the host erythrocyte, as an ordinal tier.

A tier rather than a boolean because the threshold matters and the default loses real biology: MESA and PfEMP3 are exported by any textbook and both fall below 10, while KAHRP and the FIKK kinases sit above it. Collapsing to the default would have called two of the best-known exported proteins in the organism not exported.

Absence is a real negative here and not a gap. ExportPred is a sequence model, so it was evaluated on every protein; a gene missing from all three answers is predicted NOT exported, which is the opposite of the screen columns where absence means nobody looked.

CDS_TABLE = 'plasmodb_cds.tsv.gz'
def codon_usage(base: str, log=<built-in function print>) -> pandas.DataFrame:

ENC, GC3 and CAI per gene, through the shared construction in codons.

Not reimplemented. ENC and GC3 are definitions and the CAI reference set is "the ribosomal proteins" in both arms, so two implementations could only differ by being wrong in one of them.

def build_all( dataset_root: str, log=<built-in function print>, return_mentions: bool = False):

The whole Plasmodium table from the three PlasmoDB reports, assembled in one place.

Exists so the table is reproducible rather than the product of whatever was typed at a prompt, and so the one judgement call in the assembly is written down: a gene absent from ExportPred is recorded as tier 0 rather than as missing, because ExportPred is a sequence model evaluated on every protein and its silence is a prediction of "not exported". Every other absence in this table is ignorance and stays missing.

Assembly does not write package files. return_mentions=True returns (nodes, mentions) so a build command can publish both after its checks.

PHOSPHO_DIR = 'phosphosites'
PHOSPHO_THRESHOLD = 'Site Passes Threshold [0.01]'
def phosphosites(folder: str, log=<built-in function print>) -> pandas.DataFrame:

Distinct phosphorylated residues per gene, pooled across the re-analysed studies.

Counted as distinct (gene, position) pairs and not as rows. A site-centric table still carries one row per peptidoform and per source run, so the same serine found in four experiments is four rows; summing them would count how often a protein was looked at rather than how many sites it has. That is the mistake this function exists to not make, and the difference is large -- the files hold millions of rows for tens of thousands of sites.

PALMITOME = ('palmitome', '36250062', 'Table_3.xlsx')
PALMITOME_SHEET = 'Palmitome'
PALMITOME_OBSERVED = 'Palmitoylated Proteins'
PALMITOME_PREDICTED = 'Palmitoylable Proteins'
def palmitome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Proteins observed palmitoylated. Prediction is deliberately not folded in.

SIR2_TABLE = 'plasmodb_pf3d7_sir2_perturbation.tsv'
SIR2 = (('wild type - ring', 'sir2_wt_ring'), ('wild type - trophozoite', 'sir2_wt_trophozoite'), ('wild type - schizont', 'sir2_wt_schizont'), ('sir2a KO - ring', 'sir2a_ko_ring'), ('sir2a KO - trophozoite', 'sir2a_ko_trophozoite'), ('sir2a KO - schizont', 'sir2a_ko_schizont'), ('sir2b KO - ring', 'sir2b_ko_ring'), ('sir2b KO - trophozoite', 'sir2b_ko_trophozoite'), ('sir2b KO - schizont', 'sir2b_ko_schizont'))
def sir2_perturbation(report_path: str) -> pandas.DataFrame:

Wild type and knockout intensities, shipped as stated conditions rather than as a contrast.

The knockout-minus-wild-type difference is the quantity anyone will want, and it is deliberately NOT computed here. The independent check on it came out ambiguous: Sir2a silences subtelomeric var genes, so var should rise in the sir2a knockout, and it does in ring (+0.135, p = 4e-12) and schizont (+0.130, p = 2e-38) -- but FALLS in trophozoite (-0.240, p = 3e-22). The effects are also small against a spread of 0.7. That is consistent with the canonical result being subset-specific and with var probes cross-hybridising across sixty paralogues, but it is not a clean confirmation, and a derived column carrying an unexplained sign flip would state more confidence than there is. The conditions themselves are unambiguous -- PlasmoDB names them, the scale is log intensity, and the medians align across arrays -- so those are what ship.

ALPHAFOLD_TABLE = 'plasmodb_pf3d7_alphafold.tsv'
ALPHAFOLD = (('globalMetricValue', 'mean_plddt'), ('fractionPlddtVeryLow', 'plddt_fraction_very_low'), ('fractionPlddtLow', 'plddt_fraction_low'), ('fractionPlddtConfident', 'plddt_fraction_confident'), ('fractionPlddtVeryHigh', 'plddt_fraction_very_high'))
def alphafold(report_path: str) -> pandas.DataFrame:

Model confidence per gene, keyed back to the PlasmoDB accession.

A gene can carry several UniProt accessions -- 876 of them do, mostly the variant surface families where each field isolate's allele has its own entry. The fetch tries them in order and keeps the first with a model, and uniprot_used records which, so a number can be traced to the structure it came from rather than to a gene that has eight.

MYRISTOYLOME = ('myristoylome', '34695132', 'pbio.3001408.s011.xlsx')
def myristoylome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Proteins whose capture drops when N-myristoyltransferase is inhibited.

Three states, not two, and the difference matters. A protein marked significant is a substrate; a protein in the table but not marked was assayed and is not one; a protein absent from the table was never in the pulldown at all and nothing is known about it. Collapsing the last two would claim the whole proteome had been tested for myristoylation by a single experiment that saw 609 proteins.

ACETYLOME = ('acetylome', '26813983', 'srep19722-s2.xls')
ACETYLOME_SHEET = '1. Final Ac-K List w Motifs'
ACETYL_LOCALISATION = 0.75
def acetylome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Acetylated lysines per gene, and whether the gene was seen acetylated at all.

IDENTITY_TABLE = 'plasmodb_identity.tsv'
def previous_id_index(path: str) -> dict:

{any accession this genome has used, lowercased} -> the current PF3D7_ id.

The Plasmodium arm went without an identity layer for as long as every source happened to be keyed on current accessions, and the first one that was not joined nothing at all: a 2014 ribosome-profiling deposit reports PFE0630c and PF13_0222, the chromosome-based ids that were current before the 2012 renaming, and the string join found zero of its 3,629 genes.

Two rules, both taken from the Toxoplasma layer rather than reinvented:

  • Ambiguity is withdrawn, never guessed. 66 old ids are claimed by two current genes each, which is what a gene model being SPLIT looks like from the other side; a measurement made against the old model belongs to neither half in particular, so the string resolves to nothing and the cost is countable rather than invisible.
  • A current accession outranks a previous one. A file mixing both -- most do -- must not have a live id captured by some other gene's history.
RIBOSEQ = ('riboseq', '25493618')
RIBOSEQ_STAGES = {'1': 'ring', '2': 'early_trophozoite', '3': 'late_trophozoite', '4': 'schizont', '5': 'merozoite'}
RIBOSEQ_ARMS = {'mRNA': 'mrna', 'ribosome_footprints': 'rpf'}
RIBOSEQ_FILE = re.compile('^GSM\\d+_(mRNA|ribosome_footprints)_([1-5])_rpkm\\.txt\\.gz$')
def riboseq(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Ribosome-footprint and mRNA density per gene at five points of the asexual cycle.

The measurement Pf_translation - per cell-cycle phase asks for: how much ribosome is on a transcript, per gene, at each stage of the intraerythrocytic cycle. Both arms of the experiment are read and both are kept as CONDITIONS. The ratio between them -- translation efficiency -- is deliberately NOT computed here; RIBOSEQ_TE_REFUSED in the registry note says what happened when it was.

Three things are refused rather than resolved, and together they cost about 100 of 3,629 ids:

  • an id claimed by two current genes (previous_id_index withdraws it);
  • the deposit's -a / -b split entries, which report two segments of one gene -- RPKM is already length-normalised, so neither summing nor averaging them means anything;
  • two source ids landing on one current gene inside one file, which is a MERGE seen from the other side and has the same problem.
SECRETOME = ('secretome', '28944300', '5c097a1c-efe5-4ed8-b97b-f9ba656268a6.xlsx')
SECRETOME_SHEET = 'Combined list of PfEVs antigens'
SECRETOME_STUDIES = ('PfEVs_9605', 'PfEVs_Mantel CHM 2013')
def secretome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Parasite proteins found in extracellular vesicles, and how many EV proteomes found them.

The slot asks what this parasite puts outside itself. Vesicle proteomics is one instrument for that question and it is the one with data, so the column says extracellular vesicle rather than secreted: the second word would assert a route this measurement does not establish.

ONE column, and no companion flag. A boolean completed with False for every other gene would have read as 5,720 genes tested and 5,536 negative, and the atlas would have graded the slot A at 100% coverage -- for an experiment that identified 184 proteins. The count being present IS the flag, and its absence is unknown.

The sheet's trailing rows are its reference list, which is why resolution goes through the identity index rather than a PF3D7_ regex -- the citations contain accessions.

KINASE_SUBSTRATE = ('kinase_substrate', '28680058', '41467_2017_53_MOESM3_ESM.xls')
KINASE_SUBSTRATE_SHEET = 'List of hypophosphyrlated sites'
KINASE_SUBSTRATE_HEADER = 2
KINASE_SUBSTRATE_WINDOW = 'Phosphowindow'
def kinase_substrates(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Phosphosites per gene that depend on PfCDPK1, keyed by SEQUENCE because nothing else works.

The supplement reports sites as numeric ids from the 2017 annotation (3885720(S422)), which neither the current accessions nor PlasmoDB's previous-id list carry. What it does give is the 15-residue window around each site, and a window is an identifier when it occurs in exactly one protein: 73 of 79 match one gene, 6 match none, and none matches two. A window matching more than one protein is dropped rather than assigned, the same rule the accession index follows.

The column is named for DEPENDENCE, not for substrate. A site that loses phosphorylation when a kinase is knocked down may be phosphorylated by that kinase or by something downstream of it, and the file cannot tell the two apart.

IP_MS = ('ip_ms', '28691708', 'ncomms16044-s1.pdf')
IP_MS_BAITS = {'PV1-HA': 'PF3D7_1129100', 'PV2-HA': 'PF3D7_1226900', 'EXP3-HA': 'PF3D7_1024800', 'PfEMP1B': None}
IP_MS_HEADER = re.compile('(PfEMP1B|PV1-HA|PV2-HA|EXP3-HA)\\s+(?:PfEMP1F|WT)')
IP_MS_CAPTION = re.compile('Supplementary Table \\d\\s*\\|\\s*Mass spectrometry analysis of (PfEMP1B|PV1-HA|PV2-HA|EXP3-HA)')
IP_MS_ROW = re.compile('^\\s*(.*?)\\s*((?:\\d+\\s+){7}\\d+)\\s*([<>]?[\\d.]+)?\\s*$')
IP_MS_TAIL = re.compile('False discovery rate')
def ip_ms(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Co-immunoprecipitation pairs: which parasite proteins came down with each tagged bait.

The source is a PDF, and its CAPTIONS are wrong -- the table headed parasite interacting proteins holds human ones and the table headed human holds parasite ones. So nothing here reads a caption for what a table contains. The bait comes from the column header, which names the pulldown and its control, and the organism comes from the identifier space: PF3D7_ rows are parasite, *_HUMAN rows are host and are not read here.

The check that licenses the whole assignment: a bait must be the first row of its own table. PV1 tops the PV1 pulldown at 97 and 120 spectra, PV2 tops PV2's, EXP3 tops EXP3's. A page whose first row is not its bait is a CONTINUATION page, which is a different claim and is recorded as one; a page that starts a table and fails the check is refused, because the alternative is attributing one protein's partners to another.

Two things are deliberately not represented. PfEMP1B is a transgene made from a var gene and has no accession in the table, so its 38 rows cannot become parasite-parasite pairs and are counted rather than guessed at. And a row carrying seven counts instead of eight (PIESP2, in the PV2 pulldown) is dropped: the missing number could be either arm.

IDC = ('idc_timecourse', '34668757')
IDC_METADATA = 'GSE163144_family.soft'
IDC_GENOTYPE = 'HbAA'
IDC_STRAIN = '3D7'
def idc_samples(path: str) -> dict:

{GSM: hours post-invasion} for the reference arm of the time course, from the deposit's own metadata rather than from the sample titles, which encode the genotype and not the hour.

def idc_peak(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

When in the 48-hour cycle each gene's transcript peaks, from a three-hourly series.

The slot asks for a cell-cycle timing label, and this is the measurement behind one: sixteen timepoints three hours apart, two replicates, one parasite line in unmodified red cells. The peak hour is the timepoint of the highest mean expression, which is a summary of a measurement rather than a model of one -- and it is left MISSING where a gene never rises, since the hour of a flat profile is noise wearing a number.

IDC_PERIOD = 48.0
IDC_MIN_AMPLITUDE = 0.4
PB_TRANSFER = ('pb_transfer', '28708996', 'mmc1.xlsx', 'Table S1')
PB_UNCALLED = 'Insufficient data'
def berghei_fitness(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

P. berghei knockout fitness, carried onto falciparum by the SOURCE's own ortholog column.

This is a transfer, and instruction 39 requires transfers to be visible rather than folded into the measured slot -- so the columns say pb_ and the slot says transferred from Pb. What makes this one safe is that the orthology is not mine: the screen's table names a falciparum gene per row, and no falciparum gene is named by two berghei ones, so the join is one-to-one and nothing has to be dropped for ambiguity.

It is checked against the receiving arm's OWN screen, which is the check a transfer has to pass: genes the berghei screen calls essential have a median piggyBac mutagenesis index of 0.160, slow-growing ones 0.394 and dispensable ones 0.996 -- a monotonic ordering across two species and two unrelated methods (barcoded knockouts in mice against saturation mutagenesis in culture), with 65 of 71 ribosomal proteins essential. Backwards, it would be refused.

PB_LIVER = ('pb_transfer', '31730853', 'mmc2.xlsx', 'Sheet1')
PB_LIVER_COLUMNS = {'pb_liver_log2fc': 21, 'pb_liver_power': 23}
PB_LIVER_TRANSITION = 'SG-B2 data, normalized, BS fitness-corrected'
PB_NO_POWER = 'no power'
def berghei_liver_fitness(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Liver-stage fitness of berghei knockouts, carried onto falciparum orthologs.

Two things make this safe, and both are borrowed rather than invented. The falciparum id comes from the blood-stage screen's own table -- the same consortium pairing the same mutants -- and the value is the SG-B2 transition corrected for blood-stage fitness by the authors, because the transition ends in blood and an uncorrected drop would call every blood-essential gene liver-essential.

Validated on the genes the field would name: LISP1, the UIS/ETRAMP early transcribed membrane proteins, and perforin-like protein 1 all come out reduced across this transition, which is the textbook set for liver-stage development and hepatocyte egress.

STRUCT_TABLE = ('structures', 'pf_struct_pairs.tsv')
STRUCT_TM = 0.7
def structure_similarity(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Gene pairs whose AlphaFold models superpose, from an all-against-all TM-align search.

The layer that reaches what the others cannot: structural similarity needs no orthology, so it finds relatives among the lineage-specific proteins where a sequence search returns nothing -- which in this genome is most of the exported families.

The search itself is a build step (scripts/run_foldseek.py) and its output is archived, so the threshold lives HERE. Raising or lowering it is a re-read of a table rather than an hour of compute, and the table keeps every pair the search reported.

LITERATURE_CORPUS = '.claude/skills/plasmodium-scientist/corpus/pubmed_plasmodium.jsonl'
def gene_index(nodes: pandas.DataFrame, log=<built-in function print>):

The identity index for reading falciparum genes out of free text.

The same builder the Toxoplasma arm uses, with this organism's accession shapes -- current PF3D7_, and the three older forms its literature still cites -- and this organism's identity table. Sharing the builder is the point: symbol matching in prose is where a literature layer goes wrong, and one implementation means one set of rules to get right, not two.

def literature_layer( nodes: pandas.DataFrame, base: str, log=<built-in function print>) -> tuple:

Scan the abstract corpus for gene mentions; return node columns and co-mention edges.

Composed entirely of the machinery the other arm already uses -- identity for who is named, corpus for what a document is, literature for the counting and the attention correction -- so the two arms' attention numbers mean the same thing. What is Plasmodium-specific is the index and the corpus path.

Returns (columns, edges, mentions): a frame keyed by gene, the co-mention layer in the graph's array shape, and the auditable intermediate every figure can be recomputed from.

NF54_TABLE = 'plasmodb_nf54_orthogroups.tsv'
STRAIN_LENGTH_TOLERANCE = 0.5
def strain_map(report_path: str, nodes: pandas.DataFrame) -> dict:

NF54 accession -> 3D7 accession, for orthogroups holding exactly one gene on each side.

Built from orthology and then CHECKED against protein length, because orthology is a claim about ancestry and this needs a claim about identity. The two coincide here: 96.8% of the pairs have exactly the same protein length and 99.2% are within 5%, which is what it should look like when one line was cloned from the other. Groups with more than one gene on either side are dropped rather than guessed at -- those are the paralogous surface families, where guessing would attach a measurement to the wrong member.

LACTYLOME = ('lactylome', '41417877', 'pgen.1011991.s014.xlsx')
LACTYLOME_SHEET = 'K(La)'
LACTYL_LOCALISATION = 0.75
def lactylome( dataset_root: str, mapping: dict, log=<built-in function print>) -> pandas.DataFrame:

Lysine lactylation sites per gene, reported against NF54 and resolved to 3D7.

ISOFORMS = ('isoforms', '40316999', 'SupplementaryData2.xlsx')
ISOFORM_SHEET = 'PF_sense'
NOVEL_CATEGORIES = ('novel_in_catalog', 'novel_not_in_catalog', 'fusion')
def isoforms(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Transcript models per gene, and how many of them the annotation does not contain.

PF_STAGE_COLUMNS = {'ring': ('expr_ring',), 'trophozoite': ('expr_early_trophozoite', 'expr_late_trophozoite'), 'schizont': ('expr_schizont',), 'gametocyte': ('expr_gametocyte_ii', 'expr_gametocyte_v'), 'ookinete': ('expr_ookinete',), 'oocyst': ('expr_oocyst',), 'sporozoite': ('expr_sporozoite',)}
def derived_labels( nodes: pandas.DataFrame, log=<built-in function print>) -> pandas.DataFrame:

The two DERIVED columns: peak expression across stages, and which stage a gene belongs to.

Both are computed from the stage columns and declare it, so leakage closure can exclude them together with the measurements they come from. expr_max is the maximum on a log2 scale, matching the Toxoplasma column of the same name; the stage call and its margin come from the shared construction in cellcycle, which leaves a gene unlabelled unless one stage leads the next clearly -- a label that is really a coin toss looks like a measurement in every table it reaches.

UNIPROT_TABLE = 'plasmodb_pf3d7_uniprot.tsv'
IEDB_TABLES = {'n_bcell_epitopes': 'iedb_pf_bcell_epitopes.tsv', 'n_tcell_epitopes': 'iedb_pf_tcell_epitopes.tsv'}
IEDB_TABLE = 'iedb_pf_bcell_epitopes.tsv'
IEDB_COLUMN = 'n_bcell_epitopes'
def uniprot_index(report_path: str) -> dict:

UniProt accession -> gene, for accessions that name exactly one gene.

209 accessions in PlasmoDB point at more than one gene. Those are dropped: an epitope belongs to a protein, and attaching it to whichever paralogue sorted first would be inventing the answer.

def bcell_epitopes(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Distinct antibody epitope sequences per gene.

DISTINCT sequences, not assay records: MSP1 alone carries 1,739 epitopes across 14,610 records, and counting records would rank antigens by how many groups have studied them rather than by how much of the protein antibodies recognise. Absent is absent and not zero -- IEDB records what somebody tested, and a protein nobody has raised an antibody against is not a protein without epitopes.

FEBRILE_TABLE = 'plasmodb_pf3d7_febrile.tsv'
FEBRILE = (('WT 37C', 'febrile_wt_37c'), ('WT 41C', 'febrile_wt_41c'), ('delta-LRR5-37C', 'febrile_lrr5ko_37c'), ('delta-LRR5-41C', 'febrile_lrr5ko_41c'), ('delta-DHC-37C', 'febrile_dhcko_37c'), ('delta-DHC-41C', 'febrile_dhcko_41c'))
def febrile(report_path: str) -> pandas.DataFrame:

The six conditions, as conditions. The 41-versus-37 contrast is left to the caller.

Same restraint as sir2_perturbation, for a different reason. There the check on the contrast contradicted itself; here it came out NULL: heat shock proteins move by a median log2 of +0.08 against -0.07 for everything else (p = 0.2), so a fever does not induce them measurably. That is consistent with what is known -- Plasmodium's chaperones are constitutively high rather than stress-induced -- and the genes that do rise, Maurer's cleft two-TM proteins and stevor at four to five log2, match published fever-driven surface remodelling. But a null result on the one prediction available is not a validation, and a derived column would imply it had passed one. The conditions themselves are unambiguous and are what ship.

COMPLEXES = ('complexes', '41966402', 'mmc5.xlsx')
COMPLEXES_SHEET = 'Clusters'
def complexes(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Which crosslink-derived complex a gene belongs to, and how big it is.

complex_spans_host is kept rather than dropped, and it is the informative column. Seven of the 47 clusters contain human proteins as well as parasite ones, which is not contamination -- the experiment crosslinked parasite inside erythrocyte, so a complex reaching into the host is a finding. But a parasite gene in one of those has partners this table cannot name, and a reader counting complex_size without knowing that would over-count its parasite neighbours.

EC_TABLE = 'plasmodb_pf3d7_ec.tsv'
def enzyme_classification(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

EC number per gene, curated and orthology-derived kept apart.