File size: 12,336 Bytes
3273d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1498cc9
3273d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0299f2
 
3273d7f
 
 
 
 
 
 
81a12e6
3273d7f
 
 
 
 
 
 
a0299f2
3273d7f
 
 
 
 
 
 
 
 
 
 
 
1498cc9
3273d7f
 
 
 
 
 
 
 
 
 
055bfa0
3273d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eeb45c5
 
 
 
 
 
 
 
 
 
 
3273d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eeb45c5
3273d7f
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
/* 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: "This file is larger than this copy of the site can take.",
      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: fields ? { points, fields: true, field_format: "uint8" } : { points, fields: false } }),
      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",
    starting: "Starting the GPU. This can take a few minutes",
    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);