@barivia/barmesh-mcp 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit.js +3 -1
- package/dist/deferred_feedback.js +88 -0
- package/dist/index.js +1 -1
- package/dist/inventory_format.js +69 -0
- package/dist/job_monitor.js +38 -2
- package/dist/logger.js +3 -0
- package/dist/richardson_results.js +35 -0
- package/dist/shared.js +347 -45
- package/dist/tools/barmesh_results_explorer.js +44 -33
- package/dist/tools/cfd.js +20 -8
- package/dist/tools/datasets.js +61 -27
- package/dist/tools/feedback.js +71 -6
- package/dist/tools/guide.js +41 -7
- package/dist/tools/jobs.js +117 -11
- package/dist/tools/results.js +11 -5
- package/dist/tools/training_monitor.js +25 -20
- package/dist/training_monitor_curve.js +1 -1
- package/dist/ui-delivery.js +155 -0
- package/dist/views/src/views/barmesh-results-explorer/index.html +18 -16
- package/dist/views/src/views/barmesh-training-monitor/index.html +40 -28
- package/dist/viz_links.js +55 -0
- package/package.json +1 -1
package/dist/audit.js
CHANGED
|
@@ -47,10 +47,12 @@ function extractIds(result) {
|
|
|
47
47
|
function errorCodeFrom(err) {
|
|
48
48
|
if (err && typeof err === "object") {
|
|
49
49
|
const e = err;
|
|
50
|
+
if (e.errorCode)
|
|
51
|
+
return e.errorCode;
|
|
50
52
|
if (e.httpStatus !== undefined)
|
|
51
53
|
return `http_${e.httpStatus}`;
|
|
52
54
|
const m = e.message ?? "";
|
|
53
|
-
const codeMatch = m.match(/error_code
|
|
55
|
+
const codeMatch = m.match(/error_code[=:]\s*(\w+)/);
|
|
54
56
|
if (codeMatch)
|
|
55
57
|
return codeMatch[1];
|
|
56
58
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local offline queue for barmesh_send_feedback when the API or object storage is down.
|
|
3
|
+
* Persists under ~/.barivia/deferred-feedback (or BARIVIA_FEEDBACK_QUEUE_DIR).
|
|
4
|
+
* Same layout as @barivia/barsom-mcp so both proxies share one queue directory.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
export function deferredFeedbackQueueDir() {
|
|
11
|
+
const override = process.env.BARIVIA_FEEDBACK_QUEUE_DIR?.trim();
|
|
12
|
+
if (override)
|
|
13
|
+
return path.resolve(override);
|
|
14
|
+
return path.join(os.homedir(), ".barivia", "deferred-feedback");
|
|
15
|
+
}
|
|
16
|
+
async function ensureQueueDir() {
|
|
17
|
+
const dir = deferredFeedbackQueueDir();
|
|
18
|
+
await fs.mkdir(dir, { recursive: true });
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
function entryPath(dir, id) {
|
|
22
|
+
return path.join(dir, `${id}.json`);
|
|
23
|
+
}
|
|
24
|
+
export async function enqueueDeferredFeedback(product, body, lastError) {
|
|
25
|
+
const dir = await ensureQueueDir();
|
|
26
|
+
const id = randomUUID();
|
|
27
|
+
const entry = {
|
|
28
|
+
id,
|
|
29
|
+
product,
|
|
30
|
+
created_at: new Date().toISOString(),
|
|
31
|
+
body,
|
|
32
|
+
...(lastError ? { last_error: lastError } : {}),
|
|
33
|
+
};
|
|
34
|
+
const filePath = entryPath(dir, id);
|
|
35
|
+
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), "utf8");
|
|
36
|
+
return { id, path: filePath };
|
|
37
|
+
}
|
|
38
|
+
export async function listDeferredFeedback(product) {
|
|
39
|
+
const dir = deferredFeedbackQueueDir();
|
|
40
|
+
let names;
|
|
41
|
+
try {
|
|
42
|
+
names = await fs.readdir(dir);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err.code === "ENOENT")
|
|
46
|
+
return [];
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
if (!name.endsWith(".json"))
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
const raw = await fs.readFile(path.join(dir, name), "utf8");
|
|
55
|
+
const entry = JSON.parse(raw);
|
|
56
|
+
if (product && entry.product !== product)
|
|
57
|
+
continue;
|
|
58
|
+
out.push(entry);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* skip corrupt */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
out.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Attempt to submit each queued entry via `submit`. Successful entries are deleted.
|
|
69
|
+
* Failures leave the file in place for a later retry.
|
|
70
|
+
*/
|
|
71
|
+
export async function flushDeferredFeedback(product, submit) {
|
|
72
|
+
const entries = await listDeferredFeedback(product);
|
|
73
|
+
let flushed = 0;
|
|
74
|
+
const errors = [];
|
|
75
|
+
const dir = deferredFeedbackQueueDir();
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
try {
|
|
78
|
+
await submit(entry.body);
|
|
79
|
+
await fs.unlink(entryPath(dir, entry.id)).catch(() => undefined);
|
|
80
|
+
flushed += 1;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const remaining = (await listDeferredFeedback(product)).length;
|
|
87
|
+
return { flushed, remaining, errors };
|
|
88
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as
|
|
2
|
+
import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as t,RESOURCE_MIME_TYPE as o}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as n}from"./viz-server.js";import{API_KEY as a,CLIENT_VERSION as i,apiCall as l,apiRawCall as c,loadViewHtml as m,setVizPort as d,setClientSupportsMcpApps as h}from"./shared.js";import{registerGuideTool as u}from"./tools/guide.js";import{registerDatasetsTool as p}from"./tools/datasets.js";import{registerCfdTools as b}from"./tools/cfd.js";import{registerJobsTool as f}from"./tools/jobs.js";import{registerResultsTool as _}from"./tools/results.js";import{registerResultsExplorerTool as y,RESULTS_EXPLORER_URI as v}from"./tools/barmesh_results_explorer.js";import{registerTrainingMonitorTool as g,TRAINING_MONITOR_URI as w}from"./tools/training_monitor.js";import{registerFeedbackTool as I}from"./tools/feedback.js";a||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const a=new e({name:"barmesh",version:i},{instructions:"# Barivia barmesh — CFD mesh-convergence analytics\n\nSOM-based mesh-convergence verification: compare CFD meshes of a refinement study by the\nvolume-weighted distribution their cells form on a shared self-organizing map, plus\nclassical Richardson/GCI on scalar quantities.\n\n## Two tracks\n- barmesh_mesh_convergence: high-dimensional field comparison. Symmetric KL and\n Wasserstein-1 (EMD) distances between each mesh's SOM fingerprint and a reference,\n and between consecutive meshes. Decreasing, plateauing distances toward the finest\n mesh indicate sufficiency. Complements (does not replace) numerical uncertainty analysis.\n- barmesh_richardson: classical grid-convergence index on scalar QoIs.\n\n## Workflow (read-only first)\n1. barmesh_guide_workflow — orient and confirm your plan includes CFD tools.\n2. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V).\n3. barmesh_datasets(action=upload) then preview (optional description/tags for inventory).\n4. barmesh_mesh_convergence (and/or barmesh_richardson) — returns a job id.\n5. barmesh_training_monitor(job_id) — visual MCP App with live curves (default); barmesh_jobs(action=monitor) — headless blocking snapshots; or barmesh_jobs(status) for one-shot polls every 10-20s.\n6. barmesh_results(action=get) — distances, convergence reading, and figures; then\n barmesh_results_explorer(job_id) to browse every figure interactively.\n\n## Org inventory recipe\n- Prefer `barmesh_jobs(action=inventory)` for a markdown datasets+jobs overview.\n- Or compose `barmesh_datasets(list)` + `barmesh_jobs(action=list, limit=50)` (page with `cursor`; filter with `status`/`job_type`/`has_results=true` for a results catalog).\n- Set `description`+`tags` on upload/submit/update so duplicates stay navigable. List is slim by default (no fat params).\n\n## UI delivery (barmesh_training_monitor, barmesh_results_explorer)\n\nHosts vary: some embed MCP Apps; others list ui:// but never mount them (expected on some Cursor builds). Tool results always lead with a standalone localhost URL and a structured ui_delivery block (delivery, urls, hint_for_agent) — follow hint_for_agent without user prompting.\n\n- Surface the standalone URL as a clickable markdown link. Do not bury it after metrics.\n- Env: BARIVIA_UI_DELIVERY=auto|apps|localhost|inline; BARIVIA_VIZ_PORT pins the viz port across restarts.\n- Standalone pages cross-link training monitor ↔ results explorer for the same job_id.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it.\n\n## API briefly unavailable (demos)\nIf barmesh_jobs / barmesh_results / barmesh_send_feedback (or any tool) reports the mesh analysis\nAPI temporarily unavailable: say so in one calm line, do not paste HTML/502 pages, and pause\nafter the proxy's burst retries (3 tries ~2s apart; then retry_after_sec≈30). Figures already\ndownloaded stay usable. Keep talking about the last good distances, GCI CSV, and figures —\ndegrade to \"review what we have\", not \"everything failed\". Suggest a later retry for new jobs\nor feedback.\n\n## Outages / feedback\n- barmesh_api_health probes GET /health + /ready (no R2). Use when tools fail with api_unreachable.\n- barmesh_send_feedback queues drafts under ~/.barivia/deferred-feedback when the API/gateway\n is down and flushes them on the next successful call (shared directory with barsom send_feedback).\n- API errors are slim structured lines (error_code, cause, request_id, retry_after_sec) — never raw Cloudflare HTML."});t(a,v,v,{mimeType:o},async()=>{const e=await m("barmesh-results-explorer");return{contents:[{uri:v,mimeType:o,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),t(a,w,w,{mimeType:o},async()=>{const e=await m("barmesh-training-monitor");return{contents:[{uri:w,mimeType:o,text:e??"<html><body>Training Monitor view not built yet. Run: npm run build:views</body></html>"}]}}),u(a),y(a),p(a),b(a),f(a),g(a),_(a),I(a);try{const e=await n(l,c,m);d(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("barmesh viz server failed to start:",e)}const j=a.server;j.oninitialized=()=>{const e=j.getClientCapabilities(),r=s(e);h(!!r?.mimeTypes?.includes(o))};const k=new r;await a.connect(k),console.error(`barmesh-mcp ${i} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Shared markdown inventory formatting for barmesh list/inventory. */
|
|
2
|
+
export function formatTags(tags) {
|
|
3
|
+
if (!Array.isArray(tags) || tags.length === 0)
|
|
4
|
+
return "";
|
|
5
|
+
return tags.map((t) => String(t)).filter(Boolean).join(", ");
|
|
6
|
+
}
|
|
7
|
+
export function formatDatasetInventoryLine(ds) {
|
|
8
|
+
const id = String(ds.id ?? "");
|
|
9
|
+
const name = String(ds.name ?? "");
|
|
10
|
+
const rows = ds.rows != null ? Number(ds.rows) : "?";
|
|
11
|
+
const cols = ds.cols != null ? Number(ds.cols) : "?";
|
|
12
|
+
const st = ds.status != null ? String(ds.status) : "ready";
|
|
13
|
+
const statusBit = st !== "ready" ? ` | status=${st}` : "";
|
|
14
|
+
const desc = ds.description != null && String(ds.description).trim() !== ""
|
|
15
|
+
? ` — ${String(ds.description).trim()}`
|
|
16
|
+
: "";
|
|
17
|
+
const tags = formatTags(ds.tags);
|
|
18
|
+
const tagsBit = tags ? ` [${tags}]` : "";
|
|
19
|
+
return `${name} (${id}) — ${rows}×${cols}${statusBit}${tagsBit}${desc}`;
|
|
20
|
+
}
|
|
21
|
+
export function formatJobInventoryLine(job) {
|
|
22
|
+
const id = String(job.id ?? "");
|
|
23
|
+
const st = String(job.status ?? "");
|
|
24
|
+
const label = job.label != null && job.label !== "" ? String(job.label) : null;
|
|
25
|
+
const jt = job.job_type != null ? String(job.job_type) : "?";
|
|
26
|
+
const ds = job.dataset_id != null ? String(job.dataset_id) : "";
|
|
27
|
+
const created = job.created_at != null ? String(job.created_at) : "";
|
|
28
|
+
const result = job.result_ref != null && String(job.result_ref) !== "" ? "yes" : "no";
|
|
29
|
+
const tags = formatTags(job.tags);
|
|
30
|
+
const tagsBit = tags ? ` [${tags}]` : "";
|
|
31
|
+
const desc = job.description != null && String(job.description).trim() !== ""
|
|
32
|
+
? ` — ${String(job.description).trim()}`
|
|
33
|
+
: "";
|
|
34
|
+
const head = label ? `${label} (id: ${id})` : `id: ${id}`;
|
|
35
|
+
return `${head} — ${st} | type=${jt} | dataset=${ds} | created=${created} | result=${result}${tagsBit}${desc}`;
|
|
36
|
+
}
|
|
37
|
+
export function extractJobsList(data) {
|
|
38
|
+
if (Array.isArray(data)) {
|
|
39
|
+
return { jobs: data, next_cursor: null };
|
|
40
|
+
}
|
|
41
|
+
if (data && typeof data === "object") {
|
|
42
|
+
const obj = data;
|
|
43
|
+
const jobs = Array.isArray(obj.jobs) ? obj.jobs : [];
|
|
44
|
+
const next = obj.next_cursor != null && String(obj.next_cursor) !== ""
|
|
45
|
+
? String(obj.next_cursor)
|
|
46
|
+
: null;
|
|
47
|
+
return { jobs, next_cursor: next };
|
|
48
|
+
}
|
|
49
|
+
return { jobs: [], next_cursor: null };
|
|
50
|
+
}
|
|
51
|
+
export function buildJobsListQuery(opts) {
|
|
52
|
+
const qs = new URLSearchParams();
|
|
53
|
+
if (opts.dataset_id)
|
|
54
|
+
qs.set("dataset_id", opts.dataset_id);
|
|
55
|
+
if (opts.status)
|
|
56
|
+
qs.set("status", opts.status);
|
|
57
|
+
if (opts.job_type)
|
|
58
|
+
qs.set("job_type", opts.job_type);
|
|
59
|
+
if (opts.has_results)
|
|
60
|
+
qs.set("has_results", "true");
|
|
61
|
+
if (opts.limit != null)
|
|
62
|
+
qs.set("limit", String(opts.limit));
|
|
63
|
+
if (opts.cursor)
|
|
64
|
+
qs.set("cursor", opts.cursor);
|
|
65
|
+
if (opts.fields)
|
|
66
|
+
qs.set("fields", opts.fields);
|
|
67
|
+
const s = qs.toString();
|
|
68
|
+
return s ? `/v1/jobs?${s}` : "/v1/jobs";
|
|
69
|
+
}
|
package/dist/job_monitor.js
CHANGED
|
@@ -55,6 +55,14 @@ export function snapshotFromJob(data, elapsedSec, note) {
|
|
|
55
55
|
snap.qe = Math.round(qe * 10_000) / 10_000;
|
|
56
56
|
if (te != null)
|
|
57
57
|
snap.te = Math.round(te * 10_000) / 10_000;
|
|
58
|
+
const panelTe = num(data, "panel_topographic_error");
|
|
59
|
+
if (panelTe != null)
|
|
60
|
+
snap.panel_te = Math.round(panelTe * 10_000) / 10_000;
|
|
61
|
+
if (data.kernel_complete === true)
|
|
62
|
+
snap.kernel_complete = true;
|
|
63
|
+
const failureStage = str(data, "failure_stage");
|
|
64
|
+
if (failureStage)
|
|
65
|
+
snap.failure_stage = failureStage;
|
|
58
66
|
const eta = num(data, "training_eta_sec");
|
|
59
67
|
if (eta != null && eta > 0)
|
|
60
68
|
snap.eta_sec = Math.round(eta);
|
|
@@ -79,7 +87,11 @@ export function shouldRecordSnapshot(prev, next) {
|
|
|
79
87
|
return true;
|
|
80
88
|
if (Math.abs(prev.progress_pct - next.progress_pct) >= 1)
|
|
81
89
|
return true;
|
|
82
|
-
if (prev.qe !== next.qe || prev.te !== next.te)
|
|
90
|
+
if (prev.qe !== next.qe || prev.te !== next.te || prev.panel_te !== next.panel_te)
|
|
91
|
+
return true;
|
|
92
|
+
if (prev.kernel_complete !== next.kernel_complete)
|
|
93
|
+
return true;
|
|
94
|
+
if (prev.failure_stage !== next.failure_stage)
|
|
83
95
|
return true;
|
|
84
96
|
return false;
|
|
85
97
|
}
|
|
@@ -93,6 +105,12 @@ export function formatSnapshotLine(s) {
|
|
|
93
105
|
parts.push(`QE ${s.qe.toFixed(4)}`);
|
|
94
106
|
if (s.te != null)
|
|
95
107
|
parts.push(`Epoch TE ${s.te.toFixed(4)}`);
|
|
108
|
+
if (s.panel_te != null)
|
|
109
|
+
parts.push(`Panel TE ${s.panel_te.toFixed(4)}`);
|
|
110
|
+
if (s.kernel_complete)
|
|
111
|
+
parts.push("kernel complete");
|
|
112
|
+
if (s.failure_stage)
|
|
113
|
+
parts.push(`failure_stage ${s.failure_stage}`);
|
|
96
114
|
if (s.eta_sec != null)
|
|
97
115
|
parts.push(`ETA ~${s.eta_sec}s`);
|
|
98
116
|
if (s.ordering_errors_tail?.length) {
|
|
@@ -119,6 +137,22 @@ export function formatMonitorText(result, opts) {
|
|
|
119
137
|
lines.push(result.suggested_next_step);
|
|
120
138
|
return lines.join("\n");
|
|
121
139
|
}
|
|
140
|
+
function failureStageHint(stage, error) {
|
|
141
|
+
const err = (error ?? "").toLowerCase();
|
|
142
|
+
if (stage === "preprocessing") {
|
|
143
|
+
return "Check barmesh_datasets(action=preview) and prepare job status.";
|
|
144
|
+
}
|
|
145
|
+
if (stage === "training") {
|
|
146
|
+
if (err.includes("memory") || err.includes("readonlymemory")) {
|
|
147
|
+
return "Reduce grid size or batch_size and retrain.";
|
|
148
|
+
}
|
|
149
|
+
return "Review mesh convergence parameters (grid, batch_size, features).";
|
|
150
|
+
}
|
|
151
|
+
if (stage === "visualization" || stage === "upload" || stage === "metrics") {
|
|
152
|
+
return "Re-run barmesh_training_monitor with wait_finalize=true or barmesh_results(action=render) for figures.";
|
|
153
|
+
}
|
|
154
|
+
return "Read the error above before retrying.";
|
|
155
|
+
}
|
|
122
156
|
function suggestedNextStep(job_id, data) {
|
|
123
157
|
const status = String(data.status ?? "");
|
|
124
158
|
if (status === "completed") {
|
|
@@ -130,7 +164,9 @@ function suggestedNextStep(job_id, data) {
|
|
|
130
164
|
}
|
|
131
165
|
if (status === "failed") {
|
|
132
166
|
const stage = str(data, "failure_stage");
|
|
133
|
-
|
|
167
|
+
const err = str(data, "error");
|
|
168
|
+
const hint = stage ? failureStageHint(stage, err) : "Read the error above before retrying.";
|
|
169
|
+
return `Job failed${stage ? ` at ${stage}` : ""}. ${hint}`;
|
|
134
170
|
}
|
|
135
171
|
if (status === "cancelled")
|
|
136
172
|
return `Job cancelled. Confirm with barmesh_jobs(action=status, job_id="${job_id}").`;
|
package/dist/logger.js
CHANGED
|
@@ -29,6 +29,9 @@ function emit(fields) {
|
|
|
29
29
|
export function logInfo(msg, fields = {}) {
|
|
30
30
|
emit({ ...fields, level: "info", msg });
|
|
31
31
|
}
|
|
32
|
+
export function logWarn(msg, fields = {}) {
|
|
33
|
+
emit({ ...fields, level: "warn", msg });
|
|
34
|
+
}
|
|
32
35
|
export function logAudit(fields) {
|
|
33
36
|
emit({ ...fields, event: "mcp_tool_call", level: "info", msg: "mcp_tool_call" });
|
|
34
37
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Format barmesh_results(get) text for cfd_richardson_gci jobs. */
|
|
2
|
+
export function formatRichardsonResultsSummary(jobId, data, summary) {
|
|
3
|
+
const label = data.label != null && data.label !== "" ? String(data.label) : null;
|
|
4
|
+
const header = label
|
|
5
|
+
? `Richardson/GCI results for ${label} (job_id: ${jobId})`
|
|
6
|
+
: `Richardson/GCI results for job_id: ${jobId}`;
|
|
7
|
+
const qois = summary.qoi_columns ?? [];
|
|
8
|
+
const meshOrder = summary.mesh_order_fine_to_coarse ?? [];
|
|
9
|
+
const fmt = (v) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(4) : String(v ?? "N/A"));
|
|
10
|
+
const results = summary.results ?? {};
|
|
11
|
+
const lines = [
|
|
12
|
+
header,
|
|
13
|
+
`Meshes (fine→coarse): ${meshOrder.length > 0 ? meshOrder.join(" → ") : "N/A"}`,
|
|
14
|
+
`Safety factor Fs: ${fmt(summary.safety_factor ?? 1.25)} | h source: ${String(summary.h_source ?? "n_cells_column")}`,
|
|
15
|
+
];
|
|
16
|
+
const warning = summary.topology_warning;
|
|
17
|
+
if (typeof warning === "string" && warning.length > 0) {
|
|
18
|
+
lines.push(`\n⚠ Topology: ${warning}`);
|
|
19
|
+
}
|
|
20
|
+
for (const q of qois) {
|
|
21
|
+
const triplets = results[q] ?? [];
|
|
22
|
+
if (triplets.length === 0)
|
|
23
|
+
continue;
|
|
24
|
+
lines.push(`\nQoI ${q} (${triplets.length} triplet${triplets.length === 1 ? "" : "s"}):`);
|
|
25
|
+
for (const t of triplets) {
|
|
26
|
+
const mono = t.monotonic === true ? "monotonic" : t.monotonic === false ? "non-monotonic" : "monotonicity n/a";
|
|
27
|
+
lines.push(` ${String(t.triplet ?? "?")}: p=${fmt(t.p)} f_ext=${fmt(t.f_ext)} GCI_fine=${fmt(t.gci_fine_pct)}% (${mono})`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
lines.push("\nFull table: richardson_gci.csv (one row per triplet per QoI).", "Advisory: classical GCI complements SOM fingerprint distances (barmesh_mesh_convergence).", "Use barmesh_results(action=download, figures=none, include_json=true) to save richardson_gci.csv locally.");
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
export function isRichardsonJob(summary) {
|
|
34
|
+
return String(summary.job_type ?? "") === "cfd_richardson_gci";
|
|
35
|
+
}
|