alphafold
dataRetrieve predicted protein structures from the AlphaFold database by UniProt accession, and read their confidence metrics correctly — per-residue pLDDT, the PAE matrix for multi-domain proteins, and AlphaMissense variant annotations.
AlphaFold Protein Structure Database
The AlphaFold DB (EMBL-EBI) serves precomputed structure predictions covering most of UniProt. This skill is about retrieving and correctly interpreting those predictions.
It is not a prediction service. If the sequence you care about is not in the database — a designed protein, a mutant, a complex, anything with a ligand — you need to run a predictor instead. See When not to use this database at the end.
Confidence interpretation is the part people get wrong, and it is where a wrong answer does real damage: a confidently-reported structure with a pLDDT of 40 is a guess presented as a result.
Fetching a prediction
One endpoint, keyed by UniProt accession. No authentication, no API key.
curl -s "https://alphafold.ebi.ac.uk/api/prediction/P04637"
import requests
def fetch_prediction(accession, timeout=30):
r = requests.get(
f"https://alphafold.ebi.ac.uk/api/prediction/{accession}", timeout=timeout
)
if r.status_code == 404:
raise LookupError(
f"{accession}: no model under this accession — check UniProt for a "
"primary accession or a DELETED flag before predicting")
if r.status_code == 400:
raise ValueError(f"{accession} is not a well-formed UniProt accession")
r.raise_for_status()
return r.json()
Three response codes carry meaning, and they are different problems:
| code | meaning | what to do |
|---|---|---|
| 200 | at least one record exists — not necessarily for the protein you asked for | check entryId and length, below |
| 404 | no model under this accession | resolve the accession through UniProt, then decide |
| 400 | malformed identifier — body is {"error": "Invalid identifier format. …"} |
fix it; you probably passed a gene symbol |
404 is the one that gets mishandled, because "predict it instead" is wrong at least twice:
- Secondary accessions 404, and the API does not follow the redirect.
Q15086is a secondary accession ofP04637.https://rest.uniprot.org/uniprotkb/Q15086.jsonreturnsprimaryAccession: P04637— which has a model. Retrying under the primary is the fix; running a prediction burns compute on a structure you already had. - Deleted entries 404.
A0A5S6R8N3isinactiveReasonType: DELETEDin UniProt ("Not part of a reference proteome"). There is nothing to predict — the sequence left the database. - Sequences over 2700 residues 404 — sometimes. See Proteins with no canonical model.
On 404, ask UniProt what the accession is before concluding anything.
The response is a list, it is not always length 1, and it does not always contain the
protein you asked for. It holds the canonical entry plus one record per UniProt isoform —
nine for P04637 (TP53). But when AlphaFold declined to model a protein, the canonical
record is simply missing while the isoform records remain, and falling back to records[0]
hands you a different, shorter protein without a word of warning:
| accession | protein | UniProt length | records[0] |
you get |
|---|---|---|---|---|
P11532 |
DMD dystrophin | 3685 | AF-P11532-9-F1 |
525 aa — 14% |
Q8NF91 |
SYNE1 nesprin-1 | 8797 | AF-Q8NF91-9-F1 |
977 aa — 11% |
Q09666 |
AHNAK | 5890 | AF-Q09666-2-F1 |
149 aa — 2.5% |
Q03001 |
DST dystonin | 7570 | AF-Q03001-3-F1 |
2649 aa — 35% |
Q02224 |
CENPE | 2701 | AF-Q02224-3-F1 |
2580 aa — 96% |
Q02224 is the one to worry about: 96% of the right length passes any eyeball check and is
still the wrong molecule. Match the entryId and check the length against UniProt —
the entryId match is necessary, not sufficient.
def canonical(records, accession):
"""AF-P04637-F1 is canonical; AF-P04637-9-F1 is isoform 9.
Raises rather than falling back to records[0]; the table above is what that
fallback returns, silently, for every protein over 2700 residues.
"""
want = f"AF-{accession}-F1"
for rec in records:
if rec["entryId"] == want:
return rec
raise LookupError(
f"{accession}: no canonical model. Got {[r['entryId'] for r in records]} — "
"isoform and/or third-party records only. Check the UniProt length: above "
"2700 residues AlphaFold DB has no model of the canonical sequence."
)
def full_length(rec, accession, timeout=60):
"""Confirm the record covers the whole protein UniProt declares."""
d = requests.get(f"https://rest.uniprot.org/uniprotkb/{accession}.json"
"?fields=accession,sequence", timeout=timeout).json()
declared = d["sequence"]["length"]
got = len(rec["uniprotSequence"])
if got != declared or rec["uniprotStart"] != 1:
raise ValueError(
f"{accession}: model covers {rec['uniprotStart']}-{rec['uniprotEnd']} "
f"({got} aa) of the {declared} aa UniProt declares")
return rec
Proteins with no canonical model — the 2700-residue limit
AlphaFold DB holds no model for a sequence longer than 2700 residues. Walking UniProt's declared lengths, 2026-08-18 against v6:
| accession | protein | length | API | canonical AF-…-F1? |
|---|---|---|---|---|
P42345 |
MTOR | 2549 | 200 | yes |
Q02224 |
CENPE | 2701 | 200 | no — one isoform record |
P51587 |
BRCA2 | 3418 | 404 | — |
P11532 |
DMD | 3685 | 200 | no — 14 isoform records |
Q5VST9 |
OBSCN | 7968 | 404 | — |
Q8WZ42 |
TTN titin | 34350 | 404 | — |
The cutoff is sharp and it surfaces in two shapes: 404 when nothing survives, 200 when
isoform records do. Only the first is obvious, and only the second corrupts an analysis.
There is no fragment set to stitch back together either. Older AlphaFold material refers to
-F1/-F2/-F3 models for long proteins; under v6 they are gone —
AF-Q8WZ42-F2-model_v6.cif and AF-Q8NF91-F2-model_v6.cif 404, and no -F2 or -F3
entryId is served for any accession. Check that at the current version suffix, not an old
one — AF-P04637-F1-model_v4.cif also 404s, and that is a live canonical model, so a _v4
miss tells you only that /files/ serves v6. Do not write a loop that walks F1, F2, F3…. If you need the full-length structure of a
protein this long, predict it; the database does not have it under any accession.
What each record gives you
Fields worth knowing, from a live response:
entryId AF-P04637-F1 stable model identifier
uniprotAccession P04637 echoed back
gene TP53
uniprotSequence MEEPQSDPSV... the modelled sequence
uniprotStart/End 1 / 393 residue range — NOT always 1 / len
providerId GDM who computed it — not always AlphaFold
toolUsed AlphaFold Monomer v2.0 pipeline
globalMetricValue 75.06 MEAN pLDDT over the model
fractionPlddtVeryHigh 0.527 fraction of residues >90
fractionPlddtConfident 0.071 70-90
fractionPlddtLow 0.104 50-70
fractionPlddtVeryLow 0.298 <50
latestVersion 6 model version
cifUrl / pdbUrl / bcifUrl coordinates
paeDocUrl ...predicted_aligned_error_v6.json
plddtDocUrl ...confidence_v6.json
amAnnotationsUrl ...aa-substitutions.csv AlphaMissense
Download coordinates straight from the URL in the record rather than composing the
filename yourself — the version suffix moves (_v4 in older material, _v6 at time
of writing), and a hand-built URL silently rots.
rec = full_length(canonical(fetch_prediction("P04637"), "P04637"), "P04637")
open("P04637.cif", "wb").write(requests.get(rec["cifUrl"], timeout=60).content)
open("P04637.pdb", "wb").write(requests.get(rec["pdbUrl"], timeout=60).content)
Not every record is an AlphaFold model
The endpoint also serves third-party predictions, and neither the URL nor the database name
warns you. providerId and toolUsed are what distinguish them:
| accession | protein | providerId |
toolUsed |
entryId |
|---|---|---|---|---|
P04637 |
TP53 | GDM |
AlphaFold Monomer v2.0 pipeline | AF-P04637-F1 |
P0DTC2 |
SARS-CoV-2 spike | VR3D |
ColabFold v1.5.2 | AF-0000000365840314 |
P0DTD1 |
SARS-CoV-2 rep1ab | VR3D |
ColabFold v1.5.2 | AF-0000000365840311 (one of 28, none full-length) |
GDM is Google DeepMind — a real AlphaFold model. VR3D is Viro3D (MRC-University of
Glasgow), computed with ColabFold, carrying latestVersion: 1 and a numeric entryId that
matches no AF-{accession}-F1 pattern. The whole SARS-CoV-2 proteome arrives this way:
checked 2026-08-18, AF-P0DTC2-F1-model_v6.cif and its _v4 predecessor both 404, so there
is no AlphaFold model of spike to fall back to.
Two consequences:
- Attribution. Citing Jumper et al. for a Viro3D record is wrong. The file's own PDB
header names its reference (Litvin et al., Mol Syst Biol 2025, PMID 40958060) and its own
copyright holder. Read
providerIdbefore writing a methods section, and check the licence on the file rather than assuming the CC-BY-4.0 that covers AlphaFold DB's own models. entryIdmatching. Code keyed onAF-{accession}-F1finds nothing here, socanonical()raises — which is right. You are not looking at an AlphaFold model, and that should be a decision rather than an accident.
Get the files
Everything above reads the record. This puts the artifacts on disk, which is usually what you actually wanted — coordinates to open in a viewer, PAE to plot, AlphaMissense to join against variants.
Take every URL from the record, and write a manifest beside the files recording the
model version they came from. A directory of .cif files with no version stamp cannot be
compared against a rebuild later, and AlphaFold does rebuild.
import json, os, urllib.request
ACC, OUT = "P04637", "Data/alphafold"
os.makedirs(OUT, exist_ok=True)
recs = json.loads(urllib.request.urlopen(
f"https://alphafold.ebi.ac.uk/api/prediction/{ACC}", timeout=45).read())
rec = next(r for r in recs if r["entryId"] == f"AF-{ACC}-F1")
wanted = {"cifUrl": "structure.cif", "pdbUrl": "structure.pdb",
"paeDocUrl": "pae.json", "amAnnotationsUrl": "alphamissense.csv"}
manifest = []
for key, base in wanted.items():
url = rec.get(key)
if not url: # not every entry has every artifact
print(f" {key:18} absent for this entry — skipping")
continue
dest = os.path.join(OUT, f"{ACC}_{base}")
urllib.request.urlretrieve(url, dest)
manifest.append({"field": key, "url": url, "path": dest,
"bytes": os.path.getsize(dest)})
print(f" {key:18} {manifest[-1]['bytes']:>10,} bytes -> {dest}")
with open(os.path.join(OUT, f"{ACC}_manifest.json"), "w") as fh:
json.dump({"accession": ACC, "modelVersion": rec["latestVersion"],
"provider": rec["providerId"], "tool": rec["toolUsed"],
"uniprotRange": [rec["uniprotStart"], rec["uniprotEnd"]],
"files": manifest}, fh, indent=2)
print(f"\n{len(manifest)} files, model version {rec['latestVersion']}, "
f"provider {rec['providerId']}, residues {rec['uniprotStart']}-{rec['uniprotEnd']}")
Run 2026-08-18 against model v6: four files — 363,485 B .cif, 254,339 B .pdb,
422,267 B PAE, 141,628 B AlphaMissense. Sizes move with each rebuild; the count and the
absent branch are the parts that should hold. The providerId and range go in the manifest
because they are the two things a rebuild can change under you without changing a filename.
The next(...) raises StopIteration for an accession with no canonical record — P11532,
Q02224, P0DTC2. That is the intended behaviour: fail rather than write a directory of
files for the wrong protein under the right accession's name.
amAnnotationsUrl is not always there, which is why the loop skips rather than fails.
Checked 2026-08-18 against v6, it appears only on canonical human GDM records:
| record | amAnnotationsUrl |
|---|---|
AF-P04637-F1, AF-P10636-F1, AF-Q6ZWJ8-F1 (human canonical) |
present |
AF-Q6ZWJ8-4-F1, AF-Q6ZWJ8-2-F1 (human isoforms) |
absent |
AF-P00648-F1, AF-Q8W3K0-F1 (bacterial, plant) |
absent |
AF-0000000365840314 (P0DTC2, Viro3D) |
absent |
So it is not simply "non-human yields three files" — a human isoform yields three too. Code
that indexes the key directly raises KeyError on most of the database. And note what the old
records[0] fallback did here: for P11532 it returned an isoform record, so AlphaMissense
vanished quietly along with 86% of dystrophin. Canonical human records also carry
amAnnotationsHg19Url and amAnnotationsHg38Url, the same table in genome coordinates.
Reading pLDDT
pLDDT is a per-residue confidence score from 0-100. In the PDB file it is stored in the B-factor column — every atom of a residue carries that residue's score. This is a repurposing of the field, not a real B-factor. Any tool that colours by B-factor is colouring by confidence, which is usually what you want, but never report it as a temperature factor.
The four bands, matching the fractionPlddt* fields above:
| pLDDT | band | how to treat it |
|---|---|---|
| > 90 | very high | backbone and side chains both reliable |
| 70-90 | confident | backbone reliable, side chains less so |
| 50-70 | low | treat as a hypothesis; fold may be roughly right |
| < 50 | very low | do not interpret as structure — often intrinsic disorder |
from Bio.PDB import PDBParser
st = PDBParser(QUIET=True).get_structure("af", "P04637.pdb")
offset = rec["uniprotStart"] - 1 # 0 for a full-length model
plddt = {res.id[1] + offset: next(res.get_atoms()).get_bfactor()
for res in st.get_residues()} # keyed by UniProt position
assert min(plddt) == rec["uniprotStart"] and max(plddt) == rec["uniprotEnd"]
low = [n for n, v in plddt.items() if v < 50]
print(f"{len(low)} of {len(plddt)} residues below 50 pLDDT")
That offset is not decoration — residue numbers in the file restart at 1 regardless of
where the model sits in the protein. P0DTD1 record AF-0000000365840311 declares
uniprotStart 1368 / uniprotEnd 1493, and its PDB runs ATOM 1 N LEU A 1 through
ATOM 1981 OXT SER A 126. Mapping a UniProt variant onto that model without the offset lands
you 1367 residues from the residue you meant, and nothing in the output says so. For records
with uniprotStart == 1 the offset is zero and the line changes nothing — which is exactly
why the bug stays invisible until the day it doesn't.
Two things pLDDT is not:
- It is not accuracy. It is the model's confidence. High-confidence errors exist.
- A low score is information, not a failure. Very low pLDDT correlates with intrinsic disorder. TP53 above is 30% very-low because its transactivation and regulatory regions are genuinely disordered — the model is right to be unsure.
Never report a mean pLDDT alone. globalMetricValue of 75 hides that this protein
is half very-high and a third very-low. Report the band distribution, or the pLDDT
over the region you actually care about.
Reading PAE — required for anything multi-domain
Predicted Aligned Error answers a question pLDDT cannot: if I align on residue i, how wrong is residue j? High pLDDT in two domains tells you nothing about whether their relative placement is right. PAE does.
pae = requests.get(rec["paeDocUrl"], timeout=60).json()[0]
matrix = pae["predicted_aligned_error"] # N x N, angstroms, list of lists
print(len(matrix), "residues; max PAE", pae["max_predicted_aligned_error"])
The document is a list containing one object — index [0] before reading keys.
Interpretation: low PAE (dark on the standard plot) between two regions means their relative position is confident. A block-diagonal pattern with high off-diagonal values means each domain is individually well predicted but their arrangement is not — treat the domains as separate rigid bodies and ignore the linker geometry.
If you are docking, measuring an inter-domain distance, or describing a binding site that spans domains, check PAE first. Skipping this is the single most common way an AlphaFold model gets over-interpreted.
Going from a gene symbol to an accession
The API takes accessions only; a gene symbol returns HTTP 400. Resolve it first:
curl -s "https://rest.uniprot.org/uniprotkb/search?query=gene:TP53+AND+organism_id:9606+AND+reviewed:true&fields=accession,id,protein_name&format=tsv"
# Entry Entry Name Protein names
# P04637 P53_HUMAN Cellular tumor antigen p53 ...
Always constrain by organism_id and reviewed:true. An unconstrained gene search
returns homologues across species and unreviewed TrEMBL entries, and picking the
first hit is how you end up modelling the wrong organism's protein.
AlphaMissense annotations
Records include amAnnotationsUrl, a CSV of predicted pathogenicity for every
possible single amino-acid substitution:
import pandas as pd
am = pd.read_csv(rec["amAnnotationsUrl"]) # protein_variant, am_pathogenicity, am_class
print(len(am), "rows /", len(rec["uniprotSequence"]), "residues =",
round(len(am) / len(rec["uniprotSequence"]), 3))
Expect 19 rows per residue — every substitution but the wild-type. P04637 gives 7467 =
19 × 393 and P10636 14402 = 19 × 758. Check it rather than assuming it: P0CG48
(polyubiquitin-C) returns 13034 rows with no duplicates, which is 19 × 686 against the 685
residues UniProt declares. A row count that is not a clean multiple means the AlphaMissense
release and your UniProt sequence version disagree, and a join by position will be off.
These are predictions of variant effect, not clinical determinations. They do not substitute for ClinVar or a clinical genetics assessment, and they should never be presented to a patient-facing decision.
Limits worth stating in a write-up
- One conformation. No alternative states, no apo/holo pair, no conformational ensemble. A model is one snapshot of a molecule that may have several.
- No ligands, cofactors, metals, or post-translational modifications. A model of a metalloprotein has no metal in it.
- Single chains. Database entries are monomers; complexes are not covered.
- Not mutation-aware. The model is of the canonical sequence. You cannot read a point mutant's structure off it, and swapping the residue in the file is not a prediction.
- Nothing over 2700 residues. Not "may be absent": titin (
Q8WZ42), OBSCN (Q5VST9), BRCA2 (P51587), RYR1 (P21817) and DMD (P11532) all lack a canonical model, and half of them still answer200. See Proteins with no canonical model. - Not every record is AlphaFold's. Check
providerId;VR3Drecords are ColabFold predictions from Viro3D, under their own citation and copyright.
When not to use this database
Predict instead of retrieve when you need a complex, a ligand-bound pose, a designed or mutated sequence, or a sequence with no UniProt entry. Structure prediction and protein-ligand complexes are a different job from database retrieval — reach for a prediction model, not this skill.
Try it
A self-contained check that this skill still works. Public data, no account, no key.
Data — UniProt accession P04637 (human TP53), resolved by the prediction endpoint:
https://alphafold.ebi.ac.uk/api/prediction/P04637
AlphaFold DB is CC-BY-4.0 and needs no account or licence acceptance. P04637 is the happy
path — multi-isoform, spanning the full confidence range. The accessions after it are the
counter-examples that broke an earlier version of this skill, asserted so nobody puts the
simplification back. Last confirmed reachable 2026-08-18.
import json, statistics, urllib.request, urllib.error
def api(acc):
try:
with urllib.request.urlopen(
f"https://alphafold.ebi.ac.uk/api/prediction/{acc}", timeout=45) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, None
ACC = "P04637"
code, recs = api(ACC)
# NEVER recs[0]: for a protein AlphaFold declined to model, the canonical record is
# absent and recs[0] is a short isoform of a different protein.
entry = next(r for r in recs if r["entryId"] == f"AF-{ACC}-F1")
pdb = urllib.request.urlopen(entry["pdbUrl"], timeout=60).read().decode()
plddt = [float(l[60:66]) for l in pdb.splitlines()
if l.startswith("ATOM") and l[12:16].strip() == "CA"]
print("entries :", len(recs))
print("residues :", len(plddt), "| sequence:", len(entry["uniprotSequence"]))
print("API global :", entry["globalMetricValue"])
print("recomputed mean: %.2f" % statistics.fmean(plddt))
print("min / max : %.2f / %.2f" % (min(plddt), max(plddt)))
print("provider :", entry["providerId"], "|", entry["toolUsed"])
assert len(plddt) == len(entry["uniprotSequence"]) == 393
assert abs(statistics.fmean(plddt) - entry["globalMetricValue"]) < 0.1
assert all(0 <= v <= 100 for v in plddt)
assert entry["providerId"] == "GDM"
# --- the 2700-residue limit, and what records[0] would have handed you ---
assert "AF-P42345-F1" in [r["entryId"] for r in api("P42345")[1]] # MTOR, 2549 aa: modelled
for acc, declared in [("Q02224", 2701), ("P11532", 3685), ("Q09666", 5890)]:
code, rs = api(acc)
assert code == 200 and rs, acc
assert f"AF-{acc}-F1" not in [r["entryId"] for r in rs], f"{acc} now has a canonical model"
longest = max(len(r["uniprotSequence"]) for r in rs)
assert longest < declared
print(f"{acc}: 200, {len(rs)} record(s), no canonical; longest {longest}/{declared} aa "
f"({longest/declared:.0%}); records[0] = {len(rs[0]['uniprotSequence'])} aa")
# --- 404 does not mean "predict it": Q15086 is a secondary accession of P04637 ---
assert api("Q15086")[0] == 404
prim = json.loads(urllib.request.urlopen(
"https://rest.uniprot.org/uniprotkb/Q15086.json?fields=accession", timeout=45).read())
assert prim["primaryAccession"] == "P04637"
print("Q15086 : 404 here, but UniProt resolves it to P04637 — retry, don't predict")
# --- not every record is an AlphaFold model ---
code, viral = api("P0DTC2")
assert viral[0]["providerId"] == "VR3D" and viral[0]["toolUsed"].startswith("ColabFold")
assert not viral[0]["entryId"].startswith("AF-P0DTC2")
print("P0DTC2 :", viral[0]["entryId"], "-", viral[0]["toolUsed"], "(Viro3D, not AlphaFold)")
# --- a sub-range record: file residue numbers are NOT UniProt positions ---
code, rep = api("P0DTD1")
sub = next(r for r in rep if r["uniprotStart"] != 1)
assert len(sub["uniprotSequence"]) == sub["uniprotEnd"] - sub["uniprotStart"] + 1
print(f"P0DTD1 : {len(rep)} records, none starting at 1; one covers "
f"{sub['uniprotStart']}-{sub['uniprotEnd']} of 7096 — add uniprotStart-1 before mapping")
Expect
Invariants — these hold regardless of model version, and a failure means the skill is wrong:
- The response is a list, not a dict. Indexing it as a dict is the most common way to misuse this API.
- The canonical entry is found by matching
entryId, never by position.Q02224,P11532andQ09666all answer200with no canonical record, sorecords[0]there is an isoform of a different length — 96%, 14% and 3% of the declared protein respectively. The 96% case is the dangerous one. - One pLDDT value per residue:
len(plddt) == len(entry["uniprotSequence"])(393 here). - The mean recomputed from the B-factor column matches
globalMetricValueto within 0.1 — this is what confirms pLDDT really is carried in B-factor, rather than assumed. - Every value lies in 0–100.
404is not "no structure exists anywhere" —Q15086404s and resolves toP04637.providerIdis not alwaysGDM; aVR3Drecord is a ColabFold model from Viro3D.uniprotStartis not always 1, and the coordinate file still numbers from 1.
Observed 2026-08-18 against model v6 (created 2025-08-01) — these move when AlphaFold rebuilds, so treat a mismatch as drift to investigate, not as a failure:
entries : 9
residues : 393 | sequence: 393
API global : 75.06
recomputed mean: 75.05
min / max : 32.78 / 98.69
provider : GDM | AlphaFold Monomer v2.0 pipeline
Q02224: 200, 1 record(s), no canonical; longest 2580/2701 aa (96%); records[0] = 2580 aa
P11532: 200, 14 record(s), no canonical; longest 2344/3685 aa (64%); records[0] = 525 aa
Q09666: 200, 1 record(s), no canonical; longest 149/5890 aa (3%); records[0] = 149 aa
Q15086 : 404 here, but UniProt resolves it to P04637 — retry, don't predict
P0DTC2 : AF-0000000365840314 - ColabFold v1.5.2 (Viro3D, not AlphaFold)
P0DTD1 : 28 records; one covers 1368-1493 of 7096 — add uniprotStart-1 before mapping
Sources
- AlphaFold DB — https://alphafold.ebi.ac.uk/
- API — https://alphafold.ebi.ac.uk/api-docs
- UniProt REST — https://rest.uniprot.org/
- Jumper et al. (2021) Nature 596, 583-589 — https://doi.org/10.1038/s41586-021-03819-2
- Varadi et al. (2024) Nucleic Acids Research 52, D368-D375 — https://doi.org/10.1093/nar/gkad1011
AlphaFold DB data is released by EMBL-EBI and Google DeepMind under CC-BY-4.0 — that covers
providerId: GDM records, which is most but not all of what the endpoint serves. Third-party
records carry their own citation and copyright in the file header; Viro3D (VR3D) records
name Litvin et al. (2025) Molecular Systems Biology 21, 1599 —
https://doi.org/10.1038/s44320-025-00147-9. Cite what actually produced the model.