"""Turn the raw downloads (see fetch_data.py) into small tidy CSVs in data/ used by part2_brains.py.
If a raw file is missing the corresponding derived CSV already present in data/ is kept, so the
analysis can be re-run from the derived tables alone.
"""
import csv
import os
import re
import html
import numpy as np
import phylo
from fetch_data import RAW
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, "..", "data")
# Species names in Burger et al. (2019) that differ from the Upham et al. (2019) tree.
SYNONYMS = {
"Physeter_catodon": "Physeter_macrocephalus", "Cebus_apella": "Sapajus_apella",
"Cebus_libidinosus": "Sapajus_libidinosus", "Cebus_xanthosternos": "Sapajus_xanthosternos",
"Cebus_nigritus": "Sapajus_nigritus", "Uncia_uncia": "Panthera_uncia",
"Equus_burchellii": "Equus_quagga", "Equus_asinus": "Equus_africanus",
"Taurotragus_oryx": "Tragelaphus_oryx", "Taurotragus_derbianus": "Tragelaphus_derbianus",
"Hexaprotodon_liberiensis": "Choeropsis_liberiensis", "Otaria_flavescens": "Otaria_byronia",
"Monachus_schauinslandi": "Neomonachus_schauinslandi", "Felis_manul": "Otocolobus_manul",
"Puma_yagouaroundi": "Herpailurus_yagouaroundi", "Profelis_aurata": "Caracal_aurata",
"Zaglossus_bruijni": "Zaglossus_bruijnii", "Bunopithecus_hoolock": "Hoolock_hoolock",
"Callithrix_pygmaea": "Cebuella_pygmaea", "Galago_demidoff": "Galagoides_demidoff",
"Manis_tricuspis": "Phataginus_tricuspis", "Alcelaphus_lichtensteinii": "Alcelaphus_buselaphus",
}
def _w(path, rows, cols=None):
cols = cols or list(rows[0].keys())
with open(path, "w", newline="", encoding="utf8") as f:
w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
# Records in Burger et al. (2019) that are demonstrably wrong (kept in the CSV, flagged, and
# excluded from the analyses; a sensitivity run keeps them).
FLAGS = {
"Phocoena_phocoena": "brain 1,735 g / body 142 kg is the Crile & Quiring (1940) 'porpoise' record (see Boddy "
"et al. 2012 database); other harbour-porpoise records are ~500-525 g (Haug 1987; Marino 2003)",
"Bos_taurus": "body mass 46 kg is not an adult domestic cow",
"Rattus_norvegicus": "brain 8 g is ~4x the usual ~2 g for this species",
"Mus_musculoides": "brain 1.0 g at 8 g body mass is 4x the genus median EQ",
# added in revision (referee point M1): the single record (no sample size) pairs an adult-sized brain
# (1,126 g) with a body mass of 67.5 kg; adults of this species weigh about 180-300 kg and calves about
# 40 kg (Duke OBIS-SEAMAP species account; IWDG species profile: average adult 200 kg). With an adult
# body mass the same brain gives EQ 2.1-2.9, not 5.6.
"Lagenorhynchus_albirostris": "body mass 67.5 kg is a quarter to a third of adult mass (180-300 kg); "
"single record with no sample size",
}
def well_sampled(n_brain):
"""True if the Burger et al. record states that at least three brains were measured. The column holds
integers, '', '>1', '>1,<10' and '>10'; only an integer >= 3 or '>10' counts."""
s = str(n_brain).strip()
if s == ">10":
return True
try:
return float(s) >= 3
except ValueError:
return False
def _match_tips(names, tipnames, lower=False):
"""Map Burger binomials to tree tips. Tree tips may carry suffixes (family/order or subspecies);
the exact binomial is preferred, otherwise the first tip whose first two tokens match."""
exact, first = {}, {}
for t in tipnames:
parts = t.strip("_").split("_")
key = "_".join(parts[:2])
if len(parts) == 2:
exact[key] = t
first.setdefault(key, t)
out, used = {}, set()
for nm in names:
cands = [nm, SYNONYMS.get(nm, "")]
if nm == "Physeter_catodon":
cands.append("Physeter_catodon")
tip = None
for c in cands:
if not c:
continue
k = c.lower() if lower else c
tip = exact.get(k) or first.get(k)
if tip:
break
if tip and tip not in used:
used.add(tip)
out[nm] = tip
return out
def extract_burger_and_tree():
src = os.path.join(RAW, "zenodo/burger2019_BrainAllometry_Supplement_Data.csv")
tre_a = os.path.join(RAW, "trees/alvarez2022_4705sp_mean.nwk")
tre_u = os.path.join(RAW, "trees/mamphy_mcc.tre")
if not (os.path.exists(src) and os.path.exists(tre_a) and os.path.exists(tre_u)):
print(" (raw Burger/tree files missing; keeping existing derived tables)")
return
rows = list(csv.DictReader(open(src, encoding="utf8", errors="replace")))
names = [r["Binomial"] for r in rows]
# primary tree: Alvarez-Carretero et al. 2022 (branch lengths in units of 100 Myr -> Myr)
ra = phylo.parse_newick(open(tre_a).read())
for nd in phylo.iter_nodes(ra):
nd.length *= 100.0
ma = _match_tips(names, [t.name for t in phylo.tips(ra)], lower=True)
# secondary tree: Upham et al. 2019 MCC (robustness only; see report for its problems in Cetacea)
ru = phylo.read_nexus_tree(tre_u)
mu = _match_tips(names, [t.name for t in phylo.tips(ru)])
out = []
for r in rows:
nm = r["Binomial"]
out.append(dict(species=nm, order=r["order"], family=r["family"], genus=r["genus"],
brain_g=float(r["Mean_brain_mass_g"]), body_g=float(r["Mean_body_mass_g"]),
n_brain=r["Sample_size.brain"], well_sampled=int(well_sampled(r["Sample_size.brain"])),
source=r["BrainReference1"],
tip_alvarez2022=ma.get(nm, ""), tip_upham2019=mu.get(nm, ""),
flag=FLAGS.get(nm, "")))
_w(os.path.join(DATA, "mammals_extant_burger2019.csv"), out)
for root, m, fn in ((ra, ma, "mammal_tree_pruned_alvarez2022.nwk"), (ru, mu, "mammal_tree_pruned_upham2019.nwk")):
pr = phylo.prune(root, set(m.values()))
rename = {v: k for k, v in m.items()}
for t in phylo.tips(pr):
t.name = rename[t.name]
open(os.path.join(DATA, fn), "w").write(phylo.to_newick(pr))
print(f" Burger: {len(out)} species; {len(ma)} matched to the Alvarez-Carretero 2022 tree, "
f"{len(mu)} to the Upham 2019 tree")
# Ages (Ma) for the proboscidean endocasts of Benoit et al. (2019) Table 1. 'V' = stated in the text of that
# paper; 'L' = general stratigraphic range from the literature, not re-verified in this study.
BENOIT_AGES = {
"Elephas maximus": (0.0, "V", "extant"),
"Loxodonta africana": (0.0, "V", "extant"),
"Palaeoloxodon falconeri": (0.4, "L", "Middle Pleistocene, Spinagallo Cave, Sicily (age range ~0.2-0.6 Ma)"),
"Palaeoloxodon antiquus": (0.2, "L", "Middle-Late Pleistocene"),
"Mammuthus primigenius": (0.03, "L", "Late Pleistocene"),
"Mammuthus meridionalis": (2.0, "L", "Early Pleistocene"),
"Mammuthus columbi": (0.05, "L", "Late Pleistocene"),
"Mammut americanum": (0.05, "L", "Late Pleistocene"),
"Stegodon insignis": (8.0, "V", "'unknown Miocene locality in the Himalayas' (Benoit et al.); age very uncertain (23-2 Ma)"),
"Zygolophodon borsoni": (3.5, "V", "Pliocene, Mammal Neogene zone 15"),
"Palaeomastodon beadnelli": (31.0, "V", "early Oligocene, Fayum"),
"Moeritherium lyonsi": (36.0, "V", "late Eocene, Qasr el Sagha Fm, Fayum"),
"Prorastomus sirenoides": (50.0, "V", "early Eocene (~50 Ma); sirenian"),
"Seggeurius amourensis": (50.0, "V", "early Eocene (~50 Ma); hyracoid"),
}
def extract_benoit():
src = os.path.join(RAW, "epmc/PMC6597534.xml")
if not os.path.exists(src):
return
s = open(src, encoding="utf8").read()
m = re.search(r"].*?", s, flags=re.S)
out = []
for r in re.findall(r"
]*>(.*?)
", m.group(0), flags=re.S):
cells = re.findall(r"]*?(?:/>|>(.*?))", r, flags=re.S)
# drop footnote superscripts before stripping tags
cells = [html.unescape(re.sub(r"<[^>]+>", "", re.sub(r".*?", "", c))).strip() for c in cells]
if len(cells) < 5 or cells[0] not in BENOIT_AGES:
continue
age, flag, note = BENOIT_AGES[cells[0]]
out.append(dict(species=cells[0].replace(" ", "_"), age_Ma=age, age_flag=flag, age_note=note,
ecv_cm3=float(cells[1]), brain_g=float(cells[2]), body_g=float(cells[3]),
clade="Proboscidea" if cells[0].split()[0] not in ("Prorastomus", "Seggeurius") else "other Afrotheria",
source="Benoit et al. 2019 Sci Rep 9:9323 Table 1"))
_w(os.path.join(DATA, "fossil_proboscideans_benoit2019.csv"), out)
print(f" Benoit et al.: {len(out)} proboscidean (and outgroup) endocasts")
def extract_bertrand():
src = os.path.join(RAW, "zenodo/bertrand2022_Placental_data_final.csv")
if not os.path.exists(src):
return
rows = list(csv.DictReader(open(src, encoding="utf8", errors="replace")))
out = []
for r in rows:
try:
ecv = float(r["Brain_volume_cm3"]); bm = float(r["Body_mass_g"])
except ValueError:
continue
fad = float(r["FAD_Ma"]) if r["FAD_Ma"] not in ("", "NA") else np.nan
lad = float(r["LAD_Ma"]) if r["LAD_Ma"] not in ("", "NA") else np.nan
out.append(dict(species=r["Species"], period=r["Period"], group=r["Group"], order=r["Order"],
fad_Ma=fad, lad_Ma=lad, age_Ma=np.nanmean([fad, lad]) if not (np.isnan(fad) and np.isnan(lad)) else np.nan,
body_g=bm, ecv_cm3=ecv, status=r["Status"]))
_w(os.path.join(DATA, "fossil_placentals_bertrand2022.csv"), out)
print(f" Bertrand: {len(out)} taxa with endocranial volume and body mass")
def _cell_text(cell):
"""Text of a docx table cell with superscript runs (footnote marks) removed."""
parts = []
for p in cell.paragraphs:
for run in p.runs:
if run.font.superscript:
continue
parts.append(run.text)
parts.append(" ")
return re.sub(r"\s+", " ", "".join(parts)).strip()
def _num(s):
s = s.replace(",", "").replace("–", "").replace("-", "").strip()
try:
return float(s)
except ValueError:
return np.nan
def extract_waugh():
import docx
src = os.path.join(RAW, "epmc/waugh2021/pone.0257803.s003.docx")
if not os.path.exists(src):
return
d = docx.Document(src)
t = d.tables[2] # S3 Table: fossil cetacean brain and body mass and EQ estimates
out = []
for r in t.rows[1:]:
c = [_cell_text(x) for x in r.cells]
fam, sp, spec = c[0], c[1], c[2]
age, ecv, brain, body_wt, body_b = _num(c[3]), _num(c[6]), _num(c[7]), _num(c[8]), _num(c[9])
out.append(dict(family=fam, species=sp, specimen=spec, age_Ma=age, ecv_cm3=ecv,
brain_g=brain, body_kg_waugh=body_wt, body_kg_boessenecker=body_b))
_w(os.path.join(DATA, "fossil_cetaceans_waugh2021.csv"), out)
print(f" Waugh & Thewissen S3: {len(out)} fossil cetacean specimens")
def extract_du():
import openpyxl
import warnings
src = os.path.join(RAW, "zenodo/du2018_ECV_dataset.xlsx")
if not os.path.exists(src):
return
with warnings.catch_warnings():
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(src, read_only=True, data_only=True)
rows = list(wb["Data"].iter_rows(values_only=True))
hdr = rows[0]
out = []
for r in rows[1:]:
d = dict(zip(hdr, r))
ecv = [float(d[k]) for k in ("ecv1", "ecv2", "ecv3", "ecv4", "ecv5", "ecv6")
if isinstance(d[k], (int, float))]
if not ecv:
continue
if isinstance(d["mean.date"], (int, float)):
age = float(d["mean.date"])
else:
age = 0.5 * (float(d["min.date"]) + float(d["max.date"]))
out.append(dict(specimen=d["ID"], taxon=d["lump.taxon"], age_Ma=age, ecv_cm3=float(np.mean(ecv)),
n_estimates=len(ecv)))
_w(os.path.join(DATA, "hominin_ecv_du2018.csv"), out)
print(f" Du et al.: {len(out)} hominin crania")
def extract_bird_reptile_neurons():
import openpyxl
import warnings
out = []
src = os.path.join(RAW, "kverkova/pnas.2121624119.sd01.xlsx")
if os.path.exists(src):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(src, read_only=True, data_only=True)
rows = list(wb.worksheets[0].iter_rows(values_only=True))
for r in rows[2:]:
if not r[1] or not isinstance(r[4], (int, float)):
continue
pall = r[40] if len(r) > 40 and isinstance(r[40], (int, float)) else None
tel = r[11] if isinstance(r[11], (int, float)) else None
out.append(dict(species=str(r[1]).strip().replace(" ", "_"), common=r[0], cls=r[2], group=r[3],
body_g=float(r[4]), brain_g=float(r[5]) if isinstance(r[5], (int, float)) else "",
brain_neurons=r[7] if isinstance(r[7], (int, float)) else "",
pallium_neurons=pall if pall else "", telencephalon_neurons=tel if tel else "",
method="isotropic fractionator", source="Kverkova et al. 2022 PNAS Dataset S1"))
src = os.path.join(RAW, "zenodo/sol2022_neurons.csv")
have = {o["species"] for o in out}
if os.path.exists(src):
rows = list(csv.DictReader(open(src, encoding="utf-8-sig"), delimiter=";"))
for r in rows:
nm = r["animal"].strip()
if nm in have:
continue
out.append(dict(species=nm, common="", cls="Aves", group="", body_g=float(r["Body"]),
brain_g=float(r["AbsBrain"]), brain_neurons=float(r["Neurons"]),
pallium_neurons=float(r["Pall_N"]), telencephalon_neurons=float(r["Tel_N"]),
method="isotropic fractionator",
source="Sol et al. 2022 Nat Ecol Evol (compiles Olkowicz et al. 2016 PNAS)"))
if out:
_w(os.path.join(DATA, "neurons_birds_reptiles.csv"), out)
print(f" bird/reptile neuron counts: {len(out)} species")
def extract_massen_mammals():
import openpyxl
import warnings
src = os.path.join(RAW, "massen/massen_brain_measures.xlsx")
if not os.path.exists(src):
return
with warnings.catch_warnings():
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(src, read_only=True, data_only=True)
rows = list(wb.worksheets[0].iter_rows(values_only=True))
out = []
for r in rows[1:]:
if not r[3] or not isinstance(r[5], (int, float)):
continue
if not isinstance(r[8], (int, float)):
continue
out.append(dict(species=str(r[3]).strip().replace(" ", "_"), common=r[1], order=r[4],
body_g=float(r[5]), brain_g=float(r[6]), brain_neurons=float(r[7]) * 1e6,
cortex_neurons=float(r[8]) * 1e6, method="isotropic fractionator",
source="Massen et al. 2021 Commun Biol source data (compiled from the primary IF literature)"))
_w(os.path.join(DATA, "neurons_mammals_massen2021.csv"), out)
print(f" Massen et al. mammal cortical neuron counts: {len(out)} species")
def extract_muller():
"""Specimen-level cetacean brain and body masses (independent cross-check of the delphinid EQ values)."""
import openpyxl
import warnings
src = os.path.join(RAW, "zenodo/muller2019_CCExCet.xlsx")
if not os.path.exists(src):
return
with warnings.catch_warnings():
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(src, read_only=True, data_only=True)
rows = list(wb.worksheets[0].iter_rows(values_only=True))
spec, means = {}, {}
for r in rows[4:]:
if not r[0]:
break # end of the main cetacean block (excluded data and other mammals follow)
if not (isinstance(r[2], (int, float)) and isinstance(r[3], (int, float))):
continue
(spec if r[1] else means).setdefault(r[0], []).append((float(r[2]), float(r[3])))
out = []
for sp in sorted(set(spec) | set(means)):
v = spec.get(sp) or means[sp]
out.append(dict(species=sp, n_specimens=len(v), body_kg=float(np.mean([x[0] for x in v])),
brain_g=float(np.mean([x[1] for x in v])),
source="Muller & Montgomery 2019 J Evol Biol, Table S1A (Dryad 10.5061/dryad.rm4368f)"))
_w(os.path.join(DATA, "cetaceans_muller2019.csv"), out)
print(f" Muller & Montgomery: {len(out)} cetacean species")
def check_upham_trees():
"""Data-quality check (referee point m7): node ages and the placement of Lissodelphis borealis in the two
distributed Upham et al. (2019) MCC 'target' trees, against the primary Alvarez-Carretero et al. tree."""
import json
files = {"upham2019_completed_5911sp": os.path.join(RAW, "trees/mamphy_mcc.tre"),
"upham2019_DNAonly_4098sp": os.path.join(RAW, "trees/mamphy_mcc_dnaonly.tre"),
"alvarez2022_4705sp": os.path.join(RAW, "trees/alvarez2022_4705sp_mean.nwk")}
if not all(os.path.exists(f) for f in files.values()):
return
pairs = [("Balaenoptera_musculus", "Tursiops_truncatus", "crown Cetacea (baleen-toothed whale split)"),
("Physeter", "Tursiops_truncatus", "sperm whale - dolphin"),
("Lissodelphis_borealis", "Tursiops_truncatus", "Lissodelphis borealis - bottlenose dolphin (same family)"),
("Lissodelphis_borealis", "Balaenoptera_musculus", "Lissodelphis borealis - blue whale"),
("Homo_sapiens", "Pan_troglodytes", "human - chimpanzee"),
("Homo_sapiens", "Macaca_mulatta", "human - macaque"),
("Homo_sapiens", "Mus_musculus", "human - mouse")]
out = {}
for nm, path in files.items():
if nm.startswith("alvarez"):
root = phylo.parse_newick(open(path).read())
scale = 100.0
else:
root = phylo.read_nexus_tree(path)
scale = 1.0
depth, mx = phylo.node_heights(root)
tips = phylo.tips(root)
low = {t.name.lower(): t for t in tips}
def find(prefix):
c = [t for k, t in low.items() if k.startswith(prefix.lower())]
return c[0] if c else None
def mrca(a, b):
anc = set()
n = a
while n is not None:
anc.add(id(n)); n = n.parent
n = b
while id(n) not in anc:
n = n.parent
return n
d = {}
for a, b, lab in pairs:
ta, tb = find(a), find(b)
if ta is not None and tb is not None:
d[lab] = round((mx - depth[id(mrca(ta, tb))]) * scale, 2)
t = find("Lissodelphis_borealis")
if t is not None:
d["sister_group_of_Lissodelphis_borealis"] = sorted(
{"_".join(x.name.split("_")[:2]) for x in phylo.tips(t.parent)} - {"Lissodelphis_borealis", "lissodelphis_borealis"})[:4]
out[nm] = d
out["note"] = ("node ages in Ma; both distributed Upham et al. MCC 'target' files date the baleen-toothed whale "
"split at 16.6-17.7 Ma (primary tree: 33.6 Ma) and join the right whale dolphin Lissodelphis borealis to the "
"blue whale at 3-4 Ma, i.e. inside the baleen whales, while its split from another dolphin is placed at "
"the cetacean root; this is a property of those two files, checked here, not a claim about the "
"posterior sample as a whole")
json.dump(out, open(os.path.join(DATA, "upham_tree_check.json"), "w"), indent=1)
print(" Upham tree check written")
def main():
os.makedirs(DATA, exist_ok=True)
extract_burger_and_tree()
check_upham_trees()
extract_bertrand()
extract_waugh()
extract_benoit()
extract_du()
extract_bird_reptile_neurons()
extract_massen_mammals()
extract_muller()
if __name__ == "__main__":
main()