/* Delta-V /flow API client and display logic for the study workbench. * * The browser keeps only a session token and the open study id (per-viewer * conveniences). Every study, job, model, prediction, recommendation and report * is read back from the server, so a refresh restores the real state. Failures * are thrown as ApiError and shown; nothing is computed locally instead. */ (function (root) { "use strict"; const TOKEN_KEY = "deltav-flow-token"; const STUDY_KEY = "deltav-flow-study"; class ApiError extends Error { constructor(status, message, issues, payload) { super(message); this.status = status; this.issues = issues || []; this.payload = payload || null; } } function safeStore(store) { return { get: (k) => { try { return store ? store.getItem(k) : null; } catch (e) { return null; } }, set: (k, v) => { try { if (store) v == null ? store.removeItem(k) : store.setItem(k, v); } catch (e) { /* storage blocked: the session lasts for this page only */ } }, }; } async function errorFrom(res) { let body = null; try { body = await res.json(); } catch (e) { body = null; } const detail = body && (typeof body.detail === "string" ? body.detail : Array.isArray(body.detail) ? body.detail.map((d) => d.msg).join("; ") : null); const fallback = { 401: "Your session is not recognised by this server.", 404: "Not found for this session. It may have been deleted, or created in another browser.", 409: "That conflicts with the current state. Reload and try again.", 410: "This model is no longer available. Refit the study version.", 413: "The upload is larger than the server accepts (64 MB).", 429: "The server is busy or rate-limited. Wait a moment and try again.", 503: "The server is not ready for this action.", }[res.status]; return new ApiError(res.status, detail || fallback || "The server returned an error (" + res.status + ").", body && body.issues, body); } function createClient(opts) { const fetchFn = (opts && opts.fetch) || root.fetch.bind(root); const store = safeStore(opts && "storage" in opts ? opts.storage : root.localStorage); const sleep = (opts && opts.sleep) || ((ms) => new Promise((r) => setTimeout(r, ms))); let token = store.get(TOKEN_KEY); async function session() { const res = await fetchFn("/flow/session", { method: "POST" }); if (!res.ok) throw await errorFrom(res); token = (await res.json()).token; store.set(TOKEN_KEY, token); return token; } async function request(path, o) { o = o || {}; if (!token) await session(); const send = () => fetchFn(path, { method: o.method || "GET", body: o.body, headers: Object.assign({ Authorization: "Bearer " + token }, o.json !== undefined ? { "Content-Type": "application/json" } : {}, o.headers || {}), ...(o.json !== undefined ? { body: JSON.stringify(o.json) } : {}), signal: o.signal, }); let res = await send(); if (res.status === 401 && !o.noRetry) { // Server data was reset or the token expired: start a new, empty session. store.set(STUDY_KEY, null); await session(); res = await send(); } if (!res.ok) throw await errorFrom(res); if (o.raw) return res; return res.status === 204 ? null : res.json(); } async function job(jobId, o) { return request("/flow/jobs/" + encodeURIComponent(jobId), o); } /** Resolve a 200/202 job response to its finished job, polling the status URL. */ async function settle(view, o) { o = o || {}; let current = view; while (current.status === "queued" || current.status === "running") { if (o.onUpdate) o.onUpdate(current); await sleep(o.interval || 1500); if (o.signal && o.signal.aborted) throw new ApiError(0, "Cancelled"); current = await job(current.job_id, { signal: o.signal }); } if (current.status === "failed") throw new ApiError(409, (current.error && current.error.message) || "The job failed.", [], current); if (current.status === "cancelled") throw new ApiError(409, "The job was cancelled.", [], current); return current; } return { request, job, settle, get token() { return token; }, studyId: () => store.get(STUDY_KEY), setStudyId: (id) => store.set(STUDY_KEY, id), capabilities: () => request("/flow/capabilities"), example: () => request("/flow/examples/worked"), startExample: () => request("/flow/examples/worked/start", { method: "POST" }), upload: (file) => request("/flow/studies", { method: "POST", body: file, headers: { "Content-Type": "application/zip" } }), study: (id) => request("/flow/studies/" + encodeURIComponent(id)), studies: () => request("/flow/studies"), runs: (id) => request("/flow/studies/" + encodeURIComponent(id) + "/runs"), fit: (id, body) => request("/flow/studies/" + encodeURIComponent(id) + "/fits", { method: "POST", json: body || {} }), cancel: (jobId) => request("/flow/jobs/" + encodeURIComponent(jobId) + "/cancel", { method: "POST" }), predict: (modelId, points, fields) => request("/flow/models/" + encodeURIComponent(modelId) + "/predict", { method: "POST", json: { points, fields: !!fields } }), next: (modelId, body) => request("/flow/models/" + encodeURIComponent(modelId) + "/next", { method: "POST", json: body }), addRuns: (id, parent, file) => request("/flow/studies/" + encodeURIComponent(id) + "/versions?parent_version_id=" + encodeURIComponent(parent), { method: "POST", body: file, headers: { "Content-Type": "application/zip" }, }), exampleCompletions: (ids) => request("/flow/examples/worked/completions?ids=" + encodeURIComponent(ids.join(",")), { raw: true }), report: (id) => request("/flow/studies/" + encodeURIComponent(id) + "/report"), scenario: (id, body) => request("/flow/studies/" + encodeURIComponent(id) + "/scenario", { method: "PUT", json: body }), charge: (id, body) => request("/flow/studies/" + encodeURIComponent(id) + "/charges", { method: "POST", json: body }), artifacts: (id) => request("/flow/studies/" + encodeURIComponent(id) + "/artifacts"), artifact: (id, name) => request("/flow/studies/" + encodeURIComponent(id) + "/artifacts/" + encodeURIComponent(name), { raw: true }), remove: (id) => request("/flow/studies/" + encodeURIComponent(id), { method: "DELETE" }), }; } /* ---------- display logic (pure) ---------- */ const STATE_TEXT = { queued: "Queued", loading: "Loading verified inputs and source checkpoint", training: "Training ensemble members", validating: "Checking saved members", calibrating: "Calibrating intervals", ready: "Ready", done: "Done", failed: "Failed", cancelled: "Cancelled", }; const DATA_LABEL = { generated_research_simulation: "Live prediction", customer_simulation: "Customer simulation data", other_simulation: "Other simulation data", }; const OUTCOME_LABEL = { kinetic_energy: "Kinetic energy", pressure_rms: "Pressure RMS" }; function jobText(job) { if (!job) return ""; let text = STATE_TEXT[job.state] || job.state; const p = job.progress || {}; if (job.state === "training" && p.member) { text += ": member " + p.member + " of " + p.members; if (Number.isFinite(p.step)) text += ", step " + p.step + " of " + p.steps; } return text; } /** Fraction of a fit done: members weigh equally; calibration finishes it. Never invents progress. */ function fitFraction(job) { if (!job) return 0; if (job.state === "ready") return 1; const p = job.progress || {}; if (job.state === "calibrating" || job.state === "validating") return 0.97; if (!p.member || !p.members) return 0; const done = p.member_complete ? p.member : p.member - 1 + (Number.isFinite(p.step) && p.steps ? p.step / p.steps : 0); return Math.max(0, Math.min(0.96, (done / p.members) * 0.96)); } /** How one fitted model's recorded probe intervals compare with a tolerance. A setting counts as predicted well * enough when every outcome's 90% half-width is inside its tolerance. Uses the server's recorded bands only. */ function probeCheck(bands, target) { if (!bands || !Array.isArray(bands.halfwidth) || !bands.halfwidth.length || !target || !target.every((t) => t > 0)) return null; const ratios = bands.halfwidth.map((h) => Math.max(...h.map((v, j) => v / target[j]))); const sorted = ratios.slice().sort((a, b) => a - b); const mid = sorted.length >> 1; const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; return { n: ratios.length, ready: ratios.filter((r) => r <= 1).length, median, widest: sorted[sorted.length - 1], calibrated: !!bands.calibrated, level: bands.level }; } function intervalText(calibration) { if (!calibration) return "No model"; if (calibration.status === "calibrated") return "Calibrated joint " + Math.round(calibration.level * 100) + "% interval (normalized split conformal, " + calibration.n + " calibration runs). Marginal over future queries like the calibration runs; not a per-query guarantee."; return "Uncalibrated: raw ensemble spread only (" + calibration.n + " calibration runs supplied, 39 needed). Not a confidence interval."; } /** Requirements still missing before a calibrated fit, from the server's role counts and limits. */ function missingRequirements(version, capability) { const out = []; const roles = capability.roles; const c = version.role_counts; if (c.train < roles.train.min) out.push("Add " + (roles.train.min - c.train) + " more train runs (minimum " + roles.train.min + ")."); if (c.development < roles.development.min) out.push("Add " + (roles.development.min - c.development) + " more development runs (minimum " + roles.development.min + ")."); if (c.calibration < roles.calibration.min_for_calibrated) out.push("Calibrated intervals need " + roles.calibration.min_for_calibrated + " calibration runs; " + c.calibration + " supplied. You can still request an explicitly uncalibrated, prediction-only fit."); return out; } function labelsSupplied(c) { return c.train + c.development + c.calibration + c.verification; } /** Rows for the budget panel. Negative differences are shown as added cost; unknowns stay unknown. */ function budgetRows(sc) { if (!sc || sc.status === "not_set") return []; const rows = [["Runs you had at the start", String(sc.E_existing_runs)], ["Runs added since", String(sc.A_added_runs)]]; if (Number.isFinite(sc.baseline_future_runs)) { rows.push(["Planned runs still to do", String(sc.baseline_future_runs)]); rows.push(["Against your plan", signed(sc.run_difference, "run")]); } if (sc.money) { const m = sc.money; rows.push([m.budget_difference >= 0 ? "Budget not yet spent" : "Extra cost", money(Math.abs(m.budget_difference), m.currency) + (m.incomplete ? " (incomplete)" : "")]); } return rows; } function signed(v, unit) { const n = Math.abs(v); const word = n === 1 ? unit : unit + "s"; return v >= 0 ? n + " fewer " + word + " so far" : n + " more " + word + " than planned"; } function money(v, currency) { const s = Math.abs(v).toLocaleString("en-US", { maximumFractionDigits: 2 }); return (v < 0 ? "−" : "") + s + " " + currency; } const api = { ApiError, createClient, jobText, fitFraction, probeCheck, intervalText, missingRequirements, labelsSupplied, budgetRows, money, DATA_LABEL, OUTCOME_LABEL, STATE_TEXT }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.DeltaFlowApi = api; })(typeof window !== "undefined" ? window : globalThis);