AI4deeperScience's picture
Deploy reliability-aware PHBV mass-loss predictor
fd10eac verified
Raw
History Blame Contribute Delete
39.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Research-use PHBV degradation-induced mass-loss prediction with study-aware validation boundaries.">
<link rel="icon" href="data:,">
<title>PHBV Mass-Loss Reliability Explorer</title>
<style>
:root {
color-scheme: light;
--paper: #f4f0e7;
--surface: #fffdf8;
--surface-2: #ebe6dc;
--ink: #17261f;
--muted: #59655f;
--line: #cec8bc;
--spruce: #1e6552;
--spruce-2: #164b3e;
--spruce-soft: #d9e8e1;
--clay: #b95f32;
--clay-soft: #f2dfd3;
--gold: #9a7a32;
--shadow: 0 18px 48px rgba(31, 45, 38, .10);
--radius-sm: 6px;
--radius-lg: 14px;
--font-display: Georgia, "Times New Roman", serif;
--font-body: Aptos, "Segoe UI", sans-serif;
--font-mono: "Cascadia Mono", Consolas, monospace;
}
[data-theme="dark"] {
color-scheme: dark;
--paper: #15211c;
--surface: #1d2b25;
--surface-2: #26362f;
--ink: #edf1eb;
--muted: #b6c2bb;
--line: #405048;
--spruce: #7cc4a8;
--spruce-2: #a5d7c3;
--spruce-soft: #29483d;
--clay: #ed9568;
--clay-soft: #513627;
--gold: #cfb66e;
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background:
linear-gradient(rgba(23, 38, 31, .035) 1px, transparent 1px),
linear-gradient(90deg, rgba(23, 38, 31, .035) 1px, transparent 1px),
var(--paper);
background-size: 32px 32px;
color: var(--ink);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.55;
text-wrap: pretty;
}
button, input, select { font: inherit; }
button, a, input, select, summary { -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: 3px solid var(--clay); outline-offset: 3px; }
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64px;
border-bottom: 1px solid var(--line);
}
.wordmark {
display: flex;
align-items: center;
gap: 12px;
color: var(--ink);
font-weight: 700;
letter-spacing: .01em;
}
.mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid var(--spruce);
border-radius: 50%;
color: var(--spruce);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
}
.quiet-button {
min-height: 42px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
cursor: pointer;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.quiet-button:hover { border-color: var(--spruce); background: var(--spruce-soft); }
.quiet-button:active { transform: translateY(1px); }
.hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, .72fr);
gap: clamp(32px, 6vw, 80px);
padding: clamp(56px, 9vw, 112px) 0 48px;
align-items: end;
}
.eyebrow {
margin: 0 0 14px;
color: var(--spruce);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
}
h1, h2, h3 { font-family: var(--font-display); font-weight: 500; line-height: 1.08; }
h1 { margin: 0; font-size: clamp(44px, 7vw, 82px); letter-spacing: -.045em; }
h2 { margin: 0 0 12px; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.025em; }
h3 { margin: 0; font-size: 24px; }
.lede { max-width: 760px; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2.1vw, 23px); }
.endpoint-note {
padding: 24px;
border-top: 4px solid var(--clay);
background: var(--surface);
box-shadow: var(--shadow);
}
.endpoint-note strong { display: block; margin-bottom: 8px; color: var(--clay); font-family: var(--font-mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
.endpoint-note p { margin: 0; }
.evidence-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 0 auto 40px;
border: 1px solid var(--line);
background: var(--surface);
}
.evidence-item { min-height: 124px; padding: 22px; border-right: 1px solid var(--line); }
.evidence-item:last-child { border-right: 0; }
.metric { display: block; margin-bottom: 8px; font-family: var(--font-display); font-size: 34px; line-height: 1; }
.metric-label { color: var(--muted); font-size: 14px; }
.warning-banner {
display: grid;
grid-template-columns: auto 1fr;
gap: 16px;
margin-bottom: 40px;
padding: 22px 24px;
border: 1px solid color-mix(in srgb, var(--clay) 45%, var(--line));
border-radius: var(--radius-sm);
background: var(--clay-soft);
}
.warning-code { color: var(--clay); font-family: var(--font-mono); font-size: 13px; font-weight: 700; }
.warning-banner p { margin: 0; }
.workspace {
overflow: hidden;
margin-bottom: 56px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow);
}
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); background: var(--surface-2); }
.tab {
min-height: 54px;
padding: 12px 22px;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
font-weight: 650;
transition: background 160ms ease, color 160ms ease;
}
.tab:hover { background: var(--surface); color: var(--ink); }
.tab[aria-selected="true"] { background: var(--surface); color: var(--spruce-2); box-shadow: inset 0 -3px var(--spruce); }
.panel { display: none; }
.panel.active { display: block; animation: enter 220ms ease both; }
@keyframes enter { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.tool-grid { display: grid; grid-template-columns: minmax(0, 1.16fr) minmax(340px, .84fr); min-height: 660px; }
.form-pane { padding: clamp(24px, 4vw, 44px); border-right: 1px solid var(--line); }
.result-pane { padding: clamp(24px, 4vw, 44px); background: color-mix(in srgb, var(--spruce-soft) 32%, var(--surface)); }
.section-intro { margin-bottom: 30px; }
.section-intro p { margin: 8px 0 0; color: var(--muted); }
.field-groups { display: grid; gap: 22px; }
fieldset { margin: 0; padding: 0; border: 0; }
legend {
width: 100%;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
color: var(--spruce-2);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; }
.field { min-width: 0; }
label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 650; }
input, select {
width: 100%;
min-height: 46px;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input:hover, select:hover { border-color: var(--spruce); }
input:focus, select:focus { border-color: var(--spruce); box-shadow: 0 0 0 3px color-mix(in srgb, var(--spruce) 18%, transparent); outline: 0; }
.range { display: block; margin-top: 4px; color: var(--muted); font-family: var(--font-mono); font-size: 11px; }
.primary-button {
width: 100%;
min-height: 52px;
margin-top: 26px;
border: 1px solid var(--spruce-2);
border-radius: var(--radius-sm);
background: var(--spruce-2);
color: var(--surface);
cursor: pointer;
font-weight: 750;
letter-spacing: .01em;
transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease;
}
[data-theme="dark"] .primary-button { color: #13221b; }
.primary-button:hover { filter: brightness(1.08); box-shadow: 0 10px 24px rgba(22, 75, 62, .18); transform: translateY(-1px); }
.primary-button:active { transform: translateY(1px); }
.primary-button:disabled { cursor: wait; opacity: .62; transform: none; }
.empty-state { min-height: 340px; display: grid; place-items: center; text-align: center; color: var(--muted); }
.empty-state .ring { display: grid; place-items: center; width: 92px; height: 92px; margin: 0 auto 20px; border: 1px solid var(--line); border-radius: 50%; font-family: var(--font-mono); color: var(--spruce); }
.result-block[hidden], .error-block[hidden] { display: none; }
.result-kicker { color: var(--spruce-2); font-family: var(--font-mono); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.prediction { margin: 10px 0 6px; font-family: var(--font-display); font-size: clamp(52px, 7vw, 78px); line-height: 1; letter-spacing: -.04em; }
.prediction small { font-family: var(--font-body); font-size: 18px; letter-spacing: 0; }
.band { margin: 0 0 26px; color: var(--muted); }
.band strong { color: var(--ink); }
.notes { margin: 20px 0 0; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); }
.notes h4 { margin: 0 0 10px; font-size: 15px; }
.notes ul { margin: 0; padding-left: 20px; }
.notes li + li { margin-top: 6px; }
.error-block { padding: 18px; border: 1px solid var(--clay); border-radius: var(--radius-sm); background: var(--clay-soft); color: var(--ink); }
.trajectory-pane { padding: clamp(24px, 4vw, 44px); }
.trajectory-layout { display: grid; grid-template-columns: minmax(340px, .72fr) minmax(0, 1.28fr); gap: 30px; }
.time-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 22px; }
.chart-card { min-height: 420px; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); }
#trajectoryChart { display: block; width: 100%; height: auto; overflow: visible; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 12px 0 0; color: var(--muted); font-size: 13px; }
.legend-line::before, .legend-band::before { content: ""; display: inline-block; width: 24px; height: 3px; margin-right: 7px; vertical-align: middle; background: var(--spruce); }
.legend-band::before { height: 10px; background: var(--spruce-soft); border: 1px solid var(--spruce); }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 22px 0 10px; }
.table-wrap { max-height: 320px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 14px; }
th, td { padding: 9px 12px; border-bottom: 1px solid var(--line); text-align: right; white-space: nowrap; }
th { position: sticky; top: 0; background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
th:first-child, td:first-child { text-align: left; }
.methods { display: grid; grid-template-columns: .65fr 1.35fr; gap: clamp(28px, 6vw, 72px); padding: 64px 0 80px; }
.methods-copy p { color: var(--muted); }
details { border-top: 1px solid var(--line); }
details:last-child { border-bottom: 1px solid var(--line); }
summary { padding: 18px 0; cursor: pointer; font-weight: 700; list-style: none; }
summary::after { content: "+"; float: right; color: var(--spruce); font-family: var(--font-mono); }
details[open] summary::after { content: "−"; }
details p, details ul { color: var(--muted); }
details > :not(summary) { margin-top: 0; margin-bottom: 20px; }
footer { padding: 28px 0 44px; border-top: 1px solid var(--line); color: var(--muted); font-size: 14px; }
footer a { color: var(--spruce-2); }
@media (max-width: 900px) {
.hero, .tool-grid, .trajectory-layout, .methods { grid-template-columns: 1fr; }
.evidence-strip { grid-template-columns: repeat(2, 1fr); }
.evidence-item:nth-child(2) { border-right: 0; }
.evidence-item:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.form-pane { border-right: 0; border-bottom: 1px solid var(--line); }
.result-pane { min-height: 430px; }
}
@media (max-width: 600px) {
body { font-size: 16px; }
.shell { width: min(100% - 20px, 1180px); }
.topbar { min-height: 58px; }
.wordmark span:last-child { display: none; }
.hero { padding-top: 48px; }
.evidence-strip, .fields, .time-fields { grid-template-columns: 1fr; }
.evidence-item { border-right: 0; border-bottom: 1px solid var(--line); }
.evidence-item:last-child { border-bottom: 0; }
.warning-banner { grid-template-columns: 1fr; }
.tabs { overflow-x: auto; }
.tab { flex: 1 0 auto; min-height: 48px; padding: 10px 14px; }
.form-pane, .result-pane, .trajectory-pane { padding: 22px 18px; }
.table-toolbar { align-items: flex-start; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition-duration: .01ms !important; }
}
</style>
</head>
<body>
<header class="shell topbar">
<div class="wordmark"><span class="mark" aria-hidden="true">P/H</span><span>PHBV Reliability Explorer</span></div>
<button id="themeToggle" class="quiet-button" type="button" aria-pressed="false">Switch theme</button>
</header>
<main>
<section class="shell hero" aria-labelledby="pageTitle">
<div>
<p class="eyebrow">Study-aware polymer modelling</p>
<h1 id="pageTitle">Predict a trajectory.<br>Keep the uncertainty visible.</h1>
<p class="lede">A browser-native, bounded and time-monotonic XGBoost model for literature-derived PHBV degradation-induced mass loss.</p>
</div>
<aside class="endpoint-note">
<strong>Endpoint boundary</strong>
<p>Mass loss can reflect disintegration, leaching, fragment recovery, and biological conversion. It is not proof of ultimate biodegradation or mineralisation.</p>
</aside>
</section>
<section class="shell evidence-strip" aria-label="Strict validation summary">
<div class="evidence-item"><span class="metric">16</span><span class="metric-label">independent source studies</span></div>
<div class="evidence-item"><span class="metric">23.55</span><span class="metric-label">macro-study MAE, percentage points</span></div>
<div class="evidence-item"><span class="metric">0.145</span><span class="metric-label">nested LOSO point R²</span></div>
<div class="evidence-item"><span class="metric">91.9</span><span class="metric-label">mean width of post-selection 90% intervals</span></div>
</section>
<section class="shell warning-banner" aria-label="Research use warning">
<span class="warning-code">RESEARCH USE</span>
<p>Cross-study errors were large and heterogeneous. The released corpus is additive-enriched and inherited source-level preprocessing. Use this tool to explore hypotheses and plan experiments—not for certification, regulatory decisions, safety claims, causal inference, or replacement of laboratory tests.</p>
</section>
<section class="shell workspace" aria-label="Prediction workspace">
<div class="tabs" role="tablist" aria-label="Prediction mode">
<button class="tab" id="singleTab" role="tab" aria-controls="singlePanel" aria-selected="true">Single time point</button>
<button class="tab" id="trajectoryTab" role="tab" aria-controls="trajectoryPanel" aria-selected="false">Full trajectory</button>
</div>
<section id="singlePanel" class="panel active" role="tabpanel" aria-labelledby="singleTab">
<div class="tool-grid">
<div class="form-pane">
<div class="section-intro"><h2>Describe one observation</h2><p>All fields mirror the released modelling table. Recorded marginal ranges are shown beneath numeric inputs.</p></div>
<form id="singleForm" class="field-groups"></form>
<button id="predictSingle" class="primary-button" type="button">Calculate mass-loss prediction</button>
</div>
<div class="result-pane" aria-live="polite">
<div id="singleEmpty" class="empty-state"><div><div class="ring">0–100</div><strong>Ready for a study-aware estimate</strong><p>Enter a scenario, then calculate. The reliability band will remain visible beside the point estimate.</p></div></div>
<div id="singleResult" class="result-block" hidden>
<span class="result-kicker">Bounded monotonic estimate</span>
<div class="prediction"><span id="predictionValue"></span><small>% mass loss</small></div>
<p class="band"><strong id="intervalValue"></strong><br>Post-selection 90% grouped residual band; not guaranteed for a new study.</p>
<div id="singleNotes" class="notes"></div>
</div>
<div id="singleError" class="error-block" hidden role="alert"></div>
</div>
</div>
</section>
<section id="trajectoryPanel" class="panel" role="tabpanel" aria-labelledby="trajectoryTab">
<div class="trajectory-pane">
<div class="section-intro"><h2>Trace a fixed-profile trajectory</h2><p>Only time changes along the curve. Every other formulation and environmental descriptor remains fixed.</p></div>
<div class="trajectory-layout">
<div>
<div class="time-fields">
<div class="field"><label for="startTime">Start day</label><input id="startTime" type="number" value="0" step="1"><span class="range">Suggested 0–360</span></div>
<div class="field"><label for="endTime">End day</label><input id="endTime" type="number" value="180" step="1"><span class="range">Must exceed start</span></div>
<div class="field"><label for="pointCount">Points</label><input id="pointCount" type="number" value="61" min="2" max="200" step="1"><span class="range">2–200</span></div>
</div>
<form id="trajectoryForm" class="field-groups"></form>
<button id="predictTrajectory" class="primary-button" type="button">Generate bounded trajectory</button>
<div id="trajectoryNotes" class="notes" hidden aria-live="polite"></div>
<div id="trajectoryError" class="error-block" hidden role="alert"></div>
</div>
<div>
<div class="chart-card">
<svg id="trajectoryChart" viewBox="0 0 800 430" role="img" aria-label="Predicted PHBV mass-loss trajectory with descriptive interval">
<text x="400" y="215" text-anchor="middle" fill="currentColor" opacity=".55">Generate a trajectory to populate the chart</text>
</svg>
<div class="chart-legend"><span class="legend-line">Prediction</span><span class="legend-band">Post-selection 90% residual band</span></div>
</div>
<div id="tableSection" hidden>
<div class="table-toolbar"><strong>Trajectory values</strong><button id="downloadCsv" class="quiet-button" type="button">Download CSV</button></div>
<div class="table-wrap"><table><thead><tr><th>Day</th><th>Prediction (%)</th><th>90% lower</th><th>90% upper</th></tr></thead><tbody id="trajectoryRows"></tbody></table></div>
</div>
</div>
</div>
</div>
</section>
</section>
<section class="shell methods" aria-labelledby="methodTitle">
<div class="methods-copy"><p class="eyebrow">Read before use</p><h2 id="methodTitle">A model card inside the interface</h2><p>The page reports the strict research estimate, not training fit. Browser inference keeps the method accessible without hiding its evidential limits.</p></div>
<div>
<details open><summary>What the model guarantees</summary><p>Predictions remain within 0–100% through a bounded-logit transform plus explicit output clipping and are nondecreasing with degradation time for fixed descriptors. Three raw training responses slightly above 100% were clipped for bounded fitting. These are output-shape guarantees, not guarantees of accuracy.</p></details>
<details><summary>How it was evaluated</summary><p>The primary analysis used 16 outer leave-one-study-out folds with study-grouped inner tuning. Macro-study MAE was 23.554 percentage points (95% study-bootstrap CI 17.421–30.251). Random-row R² of 0.915 is shown only as an optimistic interpolation benchmark.</p></details>
<details><summary>Why the interval is so broad</summary><p>The nominal 90% post-selection grouped residual intervals covered 97.6% overall but averaged 91.9 percentage points wide. One held-out study achieved only 37.5% coverage. The same inner folds informed tuning and residual generation, so the displayed band is not independent conformal calibration or a conditional guarantee.</p></details>
<details><summary>What applicability notes mean</summary><p>Range and category checks can flag obvious marginal departures from the recorded evidence. They do not validate a joint scenario, detect every study shift, omitted protocol variable, or new material–environment interaction, and they do not certify a prediction as reliable. Unvalidated combinations are exploratory.</p></details>
<details><summary>What population supports the model</summary><p>The released evidence base contains 1,467 rows from 129 curves and 16 studies, with 1,247 rows marked additive-present. It inherited global temperature completion for 210 rows, category coarsening, and zero filling of unreported additive concentrations. It is not a probability sample of PHBV experiments.</p></details>
<details><summary>Privacy and computation</summary><p>The model, preprocessing map, and tree traversal run locally in this browser. Inputs are not sent to a prediction API. The static files are hosted by Hugging Face Spaces.</p></details>
</div>
</section>
</main>
<footer>
<div class="shell">Source corpus: <a href="https://doi.org/10.3390/polym18070897" rel="noopener">Kotzabasaki et al. (2026)</a>. Companion reliability manuscript and archival repository citation will be added after publication. Model export: <code>phbv-bm-xgb-2026-07-26-v1</code>; released 26 July 2026.</div>
</footer>
<script>
"use strict";
const featureLabels = {
degradation_time_days: "Degradation time (days)", adjusted_hb_ratio_formulation_mol: "Adjusted HB ratio (mol%)",
adjusted_hv_ratio_formulation_mol: "Adjusted HV ratio (mol%)", t_deg: "Temperature (°C)",
additive1_percentage_wt: "Additive 1 (wt%)", additive2_percentage_wt: "Additive 2 (wt%)",
additive3_percentage_wt: "Additive 3 (wt%)", degradation_condition: "Oxygen condition",
degradation_mechanism: "Recorded degradation mechanism", additives: "Any additives?",
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", experimental_scale: "Experimental scale"
};
const defaults = {
degradation_time_days: 30, adjusted_hb_ratio_formulation_mol: 90, adjusted_hv_ratio_formulation_mol: 10,
t_deg: 27, additive1_percentage_wt: 0, additive2_percentage_wt: 0, additive3_percentage_wt: 0,
degradation_condition: "aerobic", degradation_mechanism: "microbial_enzymatic", additives: "no",
degradation_environment: "soil", additive_type_1: "not_applicable", additive_type_2: "not_applicable",
additive_type_3: "not_applicable", sample_shape_morphology: "films",
pha_degrading_microbes: "diverse_environmental", experimental_scale: "lab"
};
const groups = {
"Time and formulation": ["degradation_time_days", "adjusted_hb_ratio_formulation_mol", "adjusted_hv_ratio_formulation_mol", "t_deg"],
"Experimental context": ["degradation_condition", "degradation_mechanism", "degradation_environment", "sample_shape_morphology", "pha_degrading_microbes", "experimental_scale"],
"Additives": ["additives", "additive1_percentage_wt", "additive_type_1", "additive2_percentage_wt", "additive_type_2", "additive3_percentage_wt", "additive_type_3"]
};
let metadata;
let browserModel;
let latestRows = [];
let latestApplicabilityNotes = [];
const $ = id => document.getElementById(id);
const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"})[character]);
function fieldMarkup(feature, prefix) {
const id = `${prefix}_${feature}`;
if (metadata.numeric_features.includes(feature)) {
const range = metadata.numeric_ranges[feature];
const step = feature === "degradation_time_days" || feature === "t_deg" ? 1 : .1;
return `<div class="field"><label for="${id}">${featureLabels[feature]}</label><input id="${id}" name="${feature}" type="number" value="${defaults[feature]}" step="${step}"><span class="range">Recorded ${range.minimum}${range.maximum}</span></div>`;
}
const options = metadata.categorical_levels[feature].map(level => `<option value="${escapeHtml(level)}"${level === defaults[feature] ? " selected" : ""}>${escapeHtml(level.replaceAll("_", " "))}</option>`).join("");
return `<div class="field"><label for="${id}">${featureLabels[feature]}</label><select id="${id}" name="${feature}">${options}</select></div>`;
}
function renderForm(formId, prefix, excludeTime) {
const form = $(formId);
form.innerHTML = Object.entries(groups).map(([name, features]) => {
const visible = features.filter(feature => !(excludeTime && feature === "degradation_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 = {};
metadata.features.forEach(feature => {
if (feature === "degradation_time_days" && timeOverride !== undefined) record[feature] = Number(timeOverride);
else if (metadata.numeric_features.includes(feature)) record[feature] = Number(data.get(feature));
else record[feature] = String(data.get(feature));
});
for (const feature of metadata.numeric_features) {
if (!Number.isFinite(record[feature])) throw new Error(`${featureLabels[feature]} must be a finite number.`);
}
return record;
}
function transform(record) {
const vector = [];
browserModel.numeric_features.forEach((feature, index) => {
const value = Number(record[feature]);
vector.push(Number.isFinite(value) ? value : browserModel.numeric_imputer_medians[index]);
});
browserModel.categorical_features.forEach((feature, index) => {
const selected = String(record[feature]);
browserModel.encoder_categories[index].forEach(category => vector.push(selected === category ? 1 : 0));
});
if (vector.length !== browserModel.num_transformed_features) throw new Error("The browser preprocessing map is inconsistent with the model.");
return vector;
}
function predict(record) {
const vector = transform(record);
let margin = browserModel.base_score;
for (const tree of browserModel.trees) {
let node = 0;
while (tree.left[node] !== -1) {
const value = vector[tree.split_index[node]];
const goLeft = Number.isNaN(value) ? Boolean(tree.default_left[node]) : value < tree.split_condition[node];
node = goLeft ? tree.left[node] : tree.right[node];
}
margin += tree.split_condition[node];
}
const epsilon = browserModel.target_epsilon;
const probability = 1 / (1 + Math.exp(-Math.max(-40, Math.min(40, margin))));
const prediction = probability * (100 + 2 * epsilon) - epsilon;
return Math.max(0, Math.min(100, prediction));
}
function applicabilityNotes(record) {
const notes = [];
metadata.numeric_features.forEach(feature => {
const {minimum, maximum} = metadata.numeric_ranges[feature];
if (record[feature] < minimum || record[feature] > maximum) notes.push(`${featureLabels[feature]}=${record[feature]} is outside the recorded range ${minimum}${maximum}.`);
});
metadata.categorical_features.forEach(feature => {
if (!metadata.categorical_levels[feature].includes(record[feature])) notes.push(`${featureLabels[feature]} is an unseen category.`);
});
const additiveTotal = record.additive1_percentage_wt + record.additive2_percentage_wt + record.additive3_percentage_wt;
if (record.additives === "no" && additiveTotal > 0) notes.push("Additives is 'no', but at least one additive percentage is positive.");
if (record.additives === "yes" && additiveTotal === 0) notes.push("Additives is 'yes', but all three recorded percentages are zero.");
if (!notes.length) notes.push("All values are within recorded marginal ranges and category levels. Marginal checks do not validate this joint scenario or guarantee study-level similarity or accuracy.");
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 value = predict(record);
const halfWidth = metadata.descriptive_interval_half_widths["0.90"];
const lower = Math.max(0, value - halfWidth);
const upper = Math.min(100, value + halfWidth);
$("predictionValue").textContent = value.toFixed(2);
$("intervalValue").textContent = `${lower.toFixed(2)}% to ${upper.toFixed(2)}%`;
$("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 mass-loss prediction"; }
}
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(rows) {
const svg = $("trajectoryChart");
svg.replaceChildren();
const width = 800, height = 430, left = 68, right = 20, top = 24, bottom = 58;
const innerWidth = width - left - right, innerHeight = height - top - bottom;
const minX = rows[0].day, maxX = rows.at(-1).day;
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 i = 0; i <= 4; i++) {
const value = minX + (maxX - minX) * i / 4;
svg.append(svgNode("text", {x:x(value), y:height-24, "text-anchor":"middle", fill:"currentColor", opacity:".65", "font-size":"12"}, value.toFixed(value % 1 ? 1 : 0)));
}
const upper = rows.map(row => `${x(row.day)},${y(row.upper)}`).join(" ");
const lower = [...rows].reverse().map(row => `${x(row.day)},${y(row.lower)}`).join(" ");
svg.append(svgNode("polygon", {points:`${upper} ${lower}`, fill:"var(--spruce-soft)", stroke:"var(--spruce)", "stroke-opacity":".35"}));
const line = rows.map((row, index) => `${index ? "L" : "M"}${x(row.day)} ${y(row.prediction)}`).join(" ");
svg.append(svgNode("path", {d:line, 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-2, "text-anchor":"middle", fill:"currentColor", opacity:".75", "font-size":"13"}, "Degradation time (days)"));
const yLabel = svgNode("text", {x:15, y:(top+height-bottom)/2, "text-anchor":"middle", fill:"currentColor", opacity:".75", "font-size":"13", transform:`rotate(-90 15 ${(top+height-bottom)/2})`}, "Predicted mass loss (%)");
svg.append(yLabel);
}
function renderTable(rows) {
$("trajectoryRows").innerHTML = rows.map(row => `<tr><td>${row.day.toFixed(3)}</td><td>${row.prediction.toFixed(3)}</td><td>${row.lower.toFixed(3)}</td><td>${row.upper.toFixed(3)}</td></tr>`).join("");
$("tableSection").hidden = false;
}
function runTrajectory() {
const button = $("predictTrajectory");
button.disabled = true; button.textContent = "Generating…";
$("trajectoryError").hidden = true;
try {
const start = Number($("startTime").value), end = Number($("endTime").value), points = Number($("pointCount").value);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) throw new Error("End day must be greater than start day.");
if (!Number.isInteger(points) || points < 2 || points > 200) throw new Error("Points must be an integer between 2 and 200.");
const halfWidth = metadata.descriptive_interval_half_widths["0.90"];
latestRows = [];
for (let index = 0; index < points; index++) {
const day = start + (end - start) * index / (points - 1);
const record = readForm("trajectoryForm", day);
const prediction = predict(record);
latestRows.push({day, prediction, lower:Math.max(0,prediction-halfWidth), upper:Math.min(100,prediction+halfWidth)});
}
drawChart(latestRows); renderTable(latestRows);
const noteRecord = readForm("trajectoryForm", start);
latestApplicabilityNotes = applicabilityNotes(noteRecord);
$("trajectoryNotes").innerHTML = notesMarkup(latestApplicabilityNotes);
$("trajectoryNotes").hidden = false;
} catch (error) {
$("trajectoryError").textContent = error.message || String(error); $("trajectoryError").hidden = false;
} finally { button.disabled = false; button.textContent = "Generate bounded trajectory"; }
}
function downloadCsv() {
if (!latestRows.length) return;
const csvCell = value => `"${String(value).replaceAll('"', '""')}"`;
const modelId = metadata.model_export_id || "phbv-bm-xgb-2026-07-26-v1";
const releaseDate = metadata.release_date || "2026-07-26";
const endpoint = "degradation-induced specimen mass loss; not ultimate biodegradation or mineralisation";
const intervalStatus = "post-selection grouped residual diagnostic; not a conditional guarantee";
const intendedUse = metadata.intended_use_limits || "research hypothesis exploration only; not for certification, regulatory or safety decisions, causal inference, or replacement of laboratory testing";
const applicability = latestApplicabilityNotes.join("; ");
const header = "time_days,prediction_pct,post_selection_90_lower,post_selection_90_upper,model_export_id,release_date,endpoint,interval_status,intended_use_limits,applicability_notes";
const lines = [header, ...latestRows.map(row => [row.day.toFixed(6),row.prediction.toFixed(6),row.lower.toFixed(6),row.upper.toFixed(6),csvCell(modelId),csvCell(releaseDate),csvCell(endpoint),csvCell(intervalStatus),csvCell(intendedUse),csvCell(applicability)].join(","))];
const blob = new Blob([lines.join("\n")], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const link = document.createElement("a"); link.href = url; link.download = "phbv_mass_loss_trajectory.csv"; link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function switchTab(target) {
const single = target === "single";
$("singleTab").setAttribute("aria-selected", String(single));
$("trajectoryTab").setAttribute("aria-selected", String(!single));
$("singlePanel").classList.toggle("active", single);
$("trajectoryPanel").classList.toggle("active", !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 ? "Use paper theme" : "Use ink theme";
localStorage.setItem("phbv-theme", dark ? "dark" : "light");
if (latestRows.length) drawChart(latestRows);
}
async function initialise() {
try {
const [metadataResponse, modelResponse] = await Promise.all([fetch("model_metadata.json"), fetch("browser_model.json")]);
if (!metadataResponse.ok || !modelResponse.ok) throw new Error("Model files could not be loaded.");
[metadata, browserModel] = await Promise.all([metadataResponse.json(), modelResponse.json()]);
renderForm("singleForm", "single", false);
renderForm("trajectoryForm", "trajectory", true);
} catch (error) {
showSingleError(new Error(`Initialisation failed: ${error.message}`));
$("predictSingle").disabled = true; $("predictTrajectory").disabled = true;
}
}
$("singleTab").addEventListener("click", () => switchTab("single"));
$("trajectoryTab").addEventListener("click", () => switchTab("trajectory"));
$("predictSingle").addEventListener("click", runSingle);
$("predictTrajectory").addEventListener("click", runTrajectory);
$("downloadCsv").addEventListener("click", downloadCsv);
$("themeToggle").addEventListener("click", toggleTheme);
const savedTheme = localStorage.getItem("phbv-theme");
if (savedTheme === "dark" || (!savedTheme && window.matchMedia("(prefers-color-scheme: dark)").matches)) toggleTheme();
initialise();
</script>
</body>
</html>