# make_heatmap.py — SANA-2026-01 substrate coverage, graded per report Sections 4–7
# Requires: pip install matplotlib numpy
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import ListedColormap, BoundaryNorm
from matplotlib.patches import Rectangle
import matplotlib.cm as cm
cols = ["Option 1", "Option 2", "Scenario 1\n(recommended)"]
# Levels: 0 absent | 1 below reference | 2 present, no reference / unconfirmed | 3 claim or reference met
data = [
("Starch → maltose/dextrin",
[(2, "9,600 U\namylase"), (2, "4,500 FCC DU"), (2, "4,500 FCC DU")]),
("Maltose/dextrin → glucose",
[(0, "absent"), (2, "10 AGU"), (2, "10 AGU")]),
("Sucrose → glucose+fructose",
[(0, "absent"), (2, "200 INV"), (2, "200 INV")]),
("Lactose → glucose+galactose",
[(2, "1,600 U + 2,000 ALU\nTolerase®L\n(unit basis unconfirmed)"),
(1, "650 FCC ALU\n(14.4% of claim)"),
(3, "4,500 FCC ALU\n(claim met)")]),
("Raffinose/stachyose (legume FODMAP)",
[(0, "absent"), (1, "150 GalU\n(12.5% of study dose)"), (1, "150 GalU\n(12.5% of study dose)")]),
("Cellulose / plant cell wall",
[(2, "80 U"), (2, "250 CU"), (2, "250 CU")]),
("Phytate (mineral chelator)",
[(0, "absent"), (2, "30 FTU"), (2, "30 FTU")]),
("Bulk dietary protein",
[(2, "2,400 U +\nbromelain + papain"),
(2, "1,950 + 5,580 HUT\n(alkaline fraction\npH-exposed)"),
(2, "1,950 + 5,580 HUT\n(alkaline fraction\npH-exposed)")]),
("Gluten proline-rich peptides",
[(1, "non-specific\nproteases only"), (2, "2 unidentified\nprotease lines, 225 mg"), (0, "removed")]),
("Triglycerides",
[(1, "400 U\n(unit, acid resistance\nunknown)"), (1, "500 FIP\n(1.6% of study dose)"), (1, "500 FIP\n(1.6% of study dose)")]),
("Chloride / gastric acid",
[(1, "332 mg betaine HCl\n= 76.6 mg Cl\n(not a permitted source)"),
(0, "absent"),
(3, "252 mg KCl\n= 120 mg Cl\n(claim met)")]),
]
blues = plt.cm.Blues
cmap = ListedColormap([blues(0.03), blues(0.30), blues(0.62), blues(0.97)])
norm = BoundaryNorm([-0.5, 0.5, 1.5, 2.5, 3.5], cmap.N)
Z = np.array([[c[0] for c in cells] for _, cells in data])
fig, ax = plt.subplots(figsize=(11, 9), dpi=200)
ax.pcolormesh(Z, cmap=cmap, norm=norm, edgecolors="white", linewidth=4)
ax.invert_yaxis()
ax.set_xticks(np.arange(len(cols)) + 0.5)
ax.set_xticklabels(cols, fontsize=11)
ax.xaxis.tick_bottom()
ax.set_yticks(np.arange(len(data)) + 0.5)
ax.set_yticklabels([r for r, _ in data], fontsize=11)
for s in ax.spines.values():
s.set_visible(False)
for i, (_, cells) in enumerate(data):
for j, (lvl, txt) in enumerate(cells):
ax.text(j + 0.5, i + 0.5, txt, ha="center", va="center", fontsize=8.8,
color="white" if lvl >= 2 else ("#555555" if lvl == 0 else "#1a1a1a"))
# Dashed amber outline where Scenario 1 differs from Option 2
if j == 2 and cells[2] != cells[1]:
ax.add_patch(Rectangle((j + 0.05, i + 0.08), 0.9, 0.84, fill=False,
ec="#e0a100", lw=2, ls="--"))
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
cb = fig.colorbar(sm, ax=ax, ticks=[0, 1, 2, 3], fraction=0.035, pad=0.02, shrink=0.6)
cb.ax.set_yticklabels(["absent", "below\nreference", "present,\nno reference", "claim /\nreference met"], fontsize=10)
cb.outline.set_visible(False)
ax.set_title("Scenario 1 reaches both authorised claims; Option 2 reaches neither\n"
"and Option 1 leaves four substrate classes uncovered",
loc="left", fontsize=13.5, pad=42)
ax.text(0, 1.025, "Shading = activity vs reference dose or claim condition (white = absent, dark = met); "
"text = declared activity",
transform=ax.transAxes, fontsize=9.5, color="#555555")
fig.text(0.01, 0.005, "Dashed outline = changed from Option 2. Grading per SANA-2026-01, Sections 4–7. "
"Reference doses derive from single small trials.", fontsize=8.5, color="#555555")
plt.savefig("substrate_coverage.png", bbox_inches="tight", facecolor="white")
print("Saved substrate_coverage.png")