AI4deeperScience's picture
Deploy browser-native endpoint-audited PHBV predictor
79849ac verified
Raw
History Blame Contribute Delete
14.3 kB
"use strict";
const LABELS = {
biodegradation_time_days: "Prediction day",
adjusted_hb_ratio_formulation_mol: "Adjusted HB ratio (mol)",
adjusted_hv_ratio_formulation_mol: "Adjusted HV ratio (mol)",
t_biodeg: "Biodegradation temperature (°C)",
additive1_percentage_wt: "Additive 1 (wt%)",
additive2_percentage_wt: "Additive 2 (wt%)",
additive3_percentage_wt: "Additive 3 (wt%)",
biodegradation_condition: "Oxygen condition",
degradation_mechanism: "Degradation mechanism",
additives: "Additives present",
degradation_environment: "Environment",
additive_type_1: "Additive type 1",
additive_type_2: "Additive type 2",
additive_type_3: "Additive type 3",
sample_shape_morphology: "Specimen morphology",
pha_degrading_microbes: "PHA-degrading microbes",
};
const GROUPS = {
"Time and formulation": [
"biodegradation_time_days",
"adjusted_hb_ratio_formulation_mol",
"adjusted_hv_ratio_formulation_mol",
"sample_shape_morphology",
],
"Environment and biological context": [
"t_biodeg",
"biodegradation_condition",
"degradation_environment",
"degradation_mechanism",
"pha_degrading_microbes",
],
"Additive package": [
"additives",
"additive_type_1",
"additive1_percentage_wt",
"additive_type_2",
"additive2_percentage_wt",
"additive_type_3",
"additive3_percentage_wt",
],
};
const $ = id => document.getElementById(id);
const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;",
})[character]);
const humanise = value => String(value).replaceAll("_", " ").replace(/\b\w/g, letter => letter.toUpperCase());
const format = value => Number(value).toFixed(1);
let model;
let latestCurve;
let latestNotes = [];
function fieldMarkup(feature, prefix) {
const id = `${prefix}_${feature}`;
const value = model.default_record[feature];
if (model.numeric_features.includes(feature)) {
const schema = model.numeric_schema[feature];
const step = feature === "biodegradation_time_days" || feature === "t_biodeg" ? 1 : .1;
return `<div class="field"><label for="${id}">${LABELS[feature]}</label><input id="${id}" name="${feature}" type="number" value="${escapeHtml(value)}" step="${step}"><span class="range">Recorded ${schema.minimum}${schema.maximum}</span></div>`;
}
const options = model.categorical_schema[feature].map(level => (
`<option value="${escapeHtml(level)}"${level === value ? " selected" : ""}>${escapeHtml(humanise(level))}</option>`
)).join("");
return `<div class="field"><label for="${id}">${LABELS[feature]}</label><select id="${id}" name="${feature}">${options}</select></div>`;
}
function renderForm(formId, prefix, excludeTime) {
$(formId).innerHTML = Object.entries(GROUPS).map(([name, features]) => {
const available = new Set([...model.numeric_features, ...model.categorical_features]);
const visible = features.filter(feature => available.has(feature) && !(excludeTime && feature === "biodegradation_time_days"));
return `<fieldset><legend>${name}</legend><div class="fields">${visible.map(feature => fieldMarkup(feature, prefix)).join("")}</div></fieldset>`;
}).join("");
}
function readForm(formId, timeOverride) {
const data = new FormData($(formId));
const record = {};
[...model.numeric_features, ...model.categorical_features].forEach(feature => {
if (feature === "biodegradation_time_days" && timeOverride !== undefined) record[feature] = Number(timeOverride);
else if (model.numeric_features.includes(feature)) record[feature] = Number(data.get(feature));
else record[feature] = String(data.get(feature));
});
model.numeric_features.forEach(feature => {
if (!Number.isFinite(record[feature])) throw new Error(`${LABELS[feature]} must be a finite number.`);
});
return record;
}
function applicabilityNotes(record, additionalTimes = []) {
const notes = [];
const numericRecords = [record, ...additionalTimes.map(time => ({...record, biodegradation_time_days: time}))];
model.numeric_features.forEach(feature => {
const schema = model.numeric_schema[feature];
const observed = numericRecords.map(candidate => candidate[feature]);
if (observed.some(value => value < schema.minimum || value > schema.maximum)) {
const display = feature === "biodegradation_time_days" && additionalTimes.length
? `${Math.min(...observed)}${Math.max(...observed)}`
: String(record[feature]);
notes.push(`${LABELS[feature]} (${display}) extends beyond the recorded range ${schema.minimum}${schema.maximum}.`);
}
});
model.categorical_features.forEach(feature => {
if (!model.categorical_schema[feature].includes(record[feature])) notes.push(`${LABELS[feature]} is an unseen category.`);
});
if (!notes.length) {
notes.push("All inputs are inside recorded marginal ranges. This does not establish that the joint scenario or an unobserved study is represented.");
} else {
notes.push("Applicability flags are warnings only; distance did not reliably calibrate study-level error.");
}
return notes;
}
function notesMarkup(notes) {
return `<h4>Applicability notes</h4><ul>${notes.map(note => `<li>${escapeHtml(note)}</li>`).join("")}</ul>`;
}
function showSingleError(error) {
$("singleEmpty").hidden = true;
$("singleResult").hidden = true;
$("singleError").hidden = false;
$("singleError").textContent = error.message || String(error);
}
function runSingle() {
const button = $("predictSingle");
button.disabled = true;
button.textContent = "Calculating…";
try {
const record = readForm("singleForm");
const result = PHBVPredictor.predictRecord(model, record);
$("predictionValue").textContent = format(result.estimate);
$("baselineValue").textContent = `${format(result.global_baseline)}%`;
$("representedInterval").textContent = `${format(result.represented_lower)}% to ${format(result.represented_upper)}%`;
$("stressInterval").textContent = `${format(result.cross_study_lower)}% to ${format(result.cross_study_upper)}%`;
$("singleNotes").innerHTML = notesMarkup(applicabilityNotes(record));
$("singleEmpty").hidden = true;
$("singleError").hidden = true;
$("singleResult").hidden = false;
} catch (error) {
showSingleError(error);
} finally {
button.disabled = false;
button.textContent = "Calculate exploratory estimate";
}
}
function svgNode(name, attributes = {}, text = "") {
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, value));
if (text) node.textContent = text;
return node;
}
function drawChart(curve) {
const svg = $("curveChart");
svg.replaceChildren();
const width = 840;
const height = 470;
const left = 70;
const right = 22;
const top = 24;
const bottom = 62;
const innerWidth = width - left - right;
const innerHeight = height - top - bottom;
const minX = curve.time_days[0];
const maxX = curve.time_days[curve.time_days.length - 1];
const x = value => left + ((value - minX) / (maxX - minX)) * innerWidth;
const y = value => top + ((100 - value) / 100) * innerHeight;
for (let tick = 0; tick <= 100; tick += 25) {
svg.append(svgNode("line", {x1: left, x2: width - right, y1: y(tick), y2: y(tick), stroke: "currentColor", opacity: ".12"}));
svg.append(svgNode("text", {x: left - 12, y: y(tick) + 4, "text-anchor": "end", fill: "currentColor", opacity: ".65", "font-size": "12"}, String(tick)));
}
for (let index = 0; index <= 4; index += 1) {
const value = minX + ((maxX - minX) * index) / 4;
svg.append(svgNode("text", {x: x(value), y: height - 25, "text-anchor": "middle", fill: "currentColor", opacity: ".65", "font-size": "12"}, value.toFixed(value % 1 ? 1 : 0)));
}
const polygon = (upper, lower, fill, stroke) => {
const upperPoints = curve.time_days.map((day, index) => `${x(day)},${y(upper[index])}`).join(" ");
const lowerPoints = [...curve.time_days].reverse().map((day, reverseIndex) => {
const index = curve.time_days.length - reverseIndex - 1;
return `${x(day)},${y(lower[index])}`;
}).join(" ");
svg.append(svgNode("polygon", {points: `${upperPoints} ${lowerPoints}`, fill, stroke, "stroke-opacity": ".4"}));
};
polygon(curve.cross_study_upper, curve.cross_study_lower, "var(--clay-soft)", "var(--clay)");
polygon(curve.represented_upper, curve.represented_lower, "var(--spruce-soft)", "var(--spruce)");
const path = values => curve.time_days.map((day, index) => `${index ? "L" : "M"}${x(day)} ${y(values[index])}`).join(" ");
svg.append(svgNode("path", {d: path(curve.global_baseline), fill: "none", stroke: "var(--gold)", "stroke-width": "3", "stroke-dasharray": "7 5"}));
svg.append(svgNode("path", {d: path(curve.estimate), fill: "none", stroke: "var(--spruce)", "stroke-width": "4", "stroke-linecap": "round", "stroke-linejoin": "round"}));
svg.append(svgNode("line", {x1: left, x2: left, y1: top, y2: height - bottom, stroke: "currentColor", opacity: ".55"}));
svg.append(svgNode("line", {x1: left, x2: width - right, y1: height - bottom, y2: height - bottom, stroke: "currentColor", opacity: ".55"}));
svg.append(svgNode("text", {x: (left + width - right) / 2, y: height - 3, "text-anchor": "middle", fill: "currentColor", opacity: ".75", "font-size": "13"}, "Biodegradation time (days)"));
svg.append(svgNode("text", {x: 17, y: (top + height - bottom) / 2, "text-anchor": "middle", fill: "currentColor", opacity: ".75", "font-size": "13", transform: `rotate(-90 17 ${(top + height - bottom) / 2})`}, "Mineralization (%)"));
}
function runCurve() {
const button = $("predictCurve");
button.disabled = true;
button.textContent = "Generating…";
$("curveError").hidden = true;
try {
const startDay = Number($("startDay").value);
const endDay = Number($("endDay").value);
const points = Number($("curvePoints").value);
const monotone = $("monotone").checked;
const record = readForm("curveForm", startDay);
latestCurve = PHBVPredictor.predictCurve(model, record, {startDay, endDay, points, monotone});
latestNotes = applicabilityNotes(record, [endDay]);
drawChart(latestCurve);
$("curveNotes").innerHTML = notesMarkup(latestNotes);
$("curveNotes").hidden = false;
$("downloadCsv").disabled = false;
} catch (error) {
$("curveError").textContent = error.message || String(error);
$("curveError").hidden = false;
$("downloadCsv").disabled = true;
} finally {
button.disabled = false;
button.textContent = "Generate trajectory";
}
}
function csvCell(value) {
return `"${String(value).replaceAll('"', '""')}"`;
}
function downloadCsv() {
if (!latestCurve) return;
const header = "time_days,rf_estimate_pct,global_time_reference_pct,represented_lower_pct,represented_upper_pct,cross_study_lower_pct,cross_study_upper_pct,model_version,endpoint,interpretation_boundary,applicability_notes";
const rows = latestCurve.time_days.map((day, index) => [
day.toFixed(6), latestCurve.estimate[index].toFixed(6), latestCurve.global_baseline[index].toFixed(6),
latestCurve.represented_lower[index].toFixed(6), latestCurve.represented_upper[index].toFixed(6),
latestCurve.cross_study_lower[index].toFixed(6), latestCurve.cross_study_upper[index].toFixed(6),
csvCell(model.model_version), csvCell("CO2-based PHBV mineralization percentage"),
csvCell("research-use empirical intervals; not prospective coverage, certification, or a decision guarantee"),
csvCell(latestNotes.join("; ")),
].join(","));
const blob = new Blob([[header, ...rows].join("\n")], {type: "text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "phbv_mineralization_trajectory.csv";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function switchTab(target) {
const single = target === "single";
$("singleTab").setAttribute("aria-selected", String(single));
$("curveTab").setAttribute("aria-selected", String(!single));
$("singlePanel").classList.toggle("active", single);
$("singlePanel").hidden = !single;
$("curvePanel").classList.toggle("active", !single);
$("curvePanel").hidden = single;
}
function toggleTheme() {
const dark = document.documentElement.dataset.theme !== "dark";
document.documentElement.dataset.theme = dark ? "dark" : "light";
$("themeToggle").setAttribute("aria-pressed", String(dark));
$("themeToggle").textContent = dark ? "Paper theme" : "Ink theme";
localStorage.setItem("phbv-mineralization-theme", dark ? "dark" : "light");
if (latestCurve) drawChart(latestCurve);
}
async function initialise() {
try {
const response = await fetch("browser_model.json", {cache: "no-store"});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
model = await response.json();
if (model.format !== "phbv-sklearn-random-forest-json" || model.model_version !== "3.0.0") {
throw new Error("The downloaded model is not the ex-ante 3.0.0 browser release.");
}
renderForm("singleForm", "single", false);
renderForm("curveForm", "curve", true);
$("predictSingle").disabled = false;
$("predictCurve").disabled = false;
$("modelStatus").textContent = "Model v3.0.0 ready · local inference";
$("modelStatus").classList.add("ready");
runSingle();
} catch (error) {
$("modelStatus").textContent = "Model load failed";
$("modelStatus").classList.add("error");
showSingleError(new Error(`Initialisation failed: ${error.message}`));
}
}
$("singleTab").addEventListener("click", () => switchTab("single"));
$("curveTab").addEventListener("click", () => switchTab("curve"));
$("predictSingle").addEventListener("click", runSingle);
$("predictCurve").addEventListener("click", runCurve);
$("downloadCsv").addEventListener("click", downloadCsv);
$("themeToggle").addEventListener("click", toggleTheme);
const savedTheme = localStorage.getItem("phbv-mineralization-theme");
if (savedTheme === "dark" || (!savedTheme && window.matchMedia("(prefers-color-scheme: dark)").matches)) toggleTheme();
initialise();