feat: gate heavy solver JSON, field traceability API, GUI rod/export depth

- C: emit profiles/diagnostics/fourier only when enable flags are set; null otherwise
- API: fieldTraceability on case parse/default and solve; fix GET /solve/default options
- Tests: golden fingerprint, quality gates, C diagnostics invariants; cardQa mean empty guard
- Makefile: test-solver-sanitize ASan/UBSan target; README and COMPUTE_PLAN updates
- GUI: taper weight/MTS/guides/sinker round-trip, rod catalog, solver output toggles,
  results (profiles/diagnostics/Fourier/traceability), engineering checks and tabs
- Restore canonical WellName in base-case for regression; trace TaperGuidesCountArray

Made-with: Cursor
This commit is contained in:
2026-04-16 23:19:00 -06:00
parent 10f6ae1c2b
commit 64e9d31373
39 changed files with 1318 additions and 369 deletions

View File

@@ -17,31 +17,6 @@
"vitest": "^4.0.2"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",

View File

@@ -6,6 +6,7 @@ import crypto from "node:crypto";
import { parseCaseXml } from "./xmlParser.js";
import { runSolver, deriveTrajectoryFrictionMultiplier } from "./solverClient.js";
import { validateSurfaceCard } from "./cardQa.js";
import { buildFieldTraceability } from "./fieldTraceability.js";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../");
const DEFAULT_XML = path.join(ROOT, "data/cases/base-case.xml");
@@ -206,7 +207,7 @@ export function buildApp() {
try {
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
const parsed = await parseCaseXml(xml);
res.json(parsed);
res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) });
} catch (error) {
res.status(400).json({ error: String(error.message || error) });
}
@@ -221,7 +222,11 @@ export function buildApp() {
.json({ error: "Request body must include xml string", schemaVersion: 2 });
}
const parsed = await parseCaseXml(xml);
return res.json({ ...parsed, schemaVersion: 2 });
return res.json({
...parsed,
schemaVersion: 2,
fieldTraceability: buildFieldTraceability(parsed)
});
} catch (error) {
return res
.status(400)
@@ -288,6 +293,7 @@ export function buildApp() {
units: "SI",
parsed,
parseWarnings: parsed.warnings,
fieldTraceability: buildFieldTraceability(parsed),
surfaceCardQa,
solver: runResults.solver,
solvers: runResults.solvers,
@@ -319,7 +325,7 @@ export function buildApp() {
workflow,
parsed.model,
null,
_req.body?.options || {}
{}
);
const runMetadata = {
deterministic: true,
@@ -348,6 +354,7 @@ export function buildApp() {
units: "SI",
parsed,
parseWarnings: parsed.warnings,
fieldTraceability: buildFieldTraceability(parsed),
solver: runResults.solver,
solvers: runResults.solvers,
comparison: runResults.comparison,

View File

@@ -4,6 +4,11 @@ function median(values) {
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
function mean(values) {
if (!values.length) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
export function validateSurfaceCard(surfaceCard, options = {}) {
const minSamples = options.minSamples ?? 75;
if (!surfaceCard || !Array.isArray(surfaceCard.position) || !Array.isArray(surfaceCard.load)) {
@@ -62,7 +67,3 @@ export function validateSurfaceCard(surfaceCard, options = {}) {
steadyStateCyclesToDiscard: options.discardCycles ?? 3
};
}
function mean(values) {
return values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1);
}

View File

@@ -0,0 +1,76 @@
import { MVP_FIELDS } from "./schema.js";
/**
* Static MVP field classification aligned with docs/engineering/field-traceability.md.
*/
const CATEGORY_BY_FIELD = {
WellName: "metadata",
Company: "metadata",
PumpingSpeed: "physics",
PumpDepth: "physics",
TubingAnchorLocation: "physics",
RodFrictionCoefficient: "physics",
StuffingBoxFriction: "physics",
PumpFriction: "physics",
WaterCut: "physics",
MeasuredDepthArray: "physics",
InclinationFromVerticalArray: "physics",
AzimuthFromNorthArray: "physics",
UnitsSelection: "parseCalibration",
UpStrokeDampingFactor: "physics",
DownStrokeDampingFactor: "physics",
NonDimensionalFluidDamping: "physics",
TaperDiameterArray: "physics",
TaperLengthArray: "physics",
TaperModulusArray: "physics",
TaperWeightArray: "physics",
TaperMTSArray: "physics",
TaperGuidesCountArray: "physics",
RodTypeArray: "physics",
MoldedGuideFrictionRatio: "physics",
WheeledGuideFrictionRatio: "physics",
OtherGuideFrictionRatio: "physics",
PumpDiameter: "physics",
PumpIntakePressure: "physics",
TubingSize: "payloadInactive",
TubingGradient: "parsedUnused",
PumpFillageOption: "physics",
PercentPumpFillage: "physics",
PercentageUpstrokeTime: "payloadInactive",
PercentageDownstrokeTime: "payloadInactive",
PumpingUnitID: "parsedUnused",
PumpingSpeedOption: "parsedUnused",
FluidLevelOilGravity: "physics",
WaterSpecGravity: "physics",
RodGuideTypeArray: "parsedUnused",
RodGuideWeightArray: "physics",
SinkerBarDiameter: "physics",
SinkerBarLength: "physics"
};
const NOTES_BY_FIELD = {
TubingSize: "Forwarded as tubing_id_m; annular refinements not yet in primary equations.",
TubingGradient: "Parsed to tubingGradientPaM; not included in C stdin payload.",
PercentageUpstrokeTime: "Payload percent_upstroke_time; harmonic kinematics still dominant.",
PercentageDownstrokeTime: "Payload percent_downstroke_time; harmonic kinematics still dominant.",
PumpingUnitID: "Parsed for future pumping-unit geometry tables.",
PumpingSpeedOption: "Parsed for future drive/kinematics modes.",
RodGuideTypeArray: "Parsed string; type-specific friction law not yet wired to C.",
UnitsSelection: "Controls imperial/SI normalization during XML parse."
};
/**
* @param {{ rawFields?: Record<string, unknown> } | null | undefined} parsed
*/
export function buildFieldTraceability(parsed) {
const raw = parsed && typeof parsed === "object" && parsed.rawFields ? parsed.rawFields : {};
return {
schemaVersion: 2,
fields: MVP_FIELDS.map((xmlKey) => ({
xmlKey,
category: CATEGORY_BY_FIELD[xmlKey] ?? "physics",
presentInXml: Object.prototype.hasOwnProperty.call(raw, xmlKey),
notes: NOTES_BY_FIELD[xmlKey] ?? ""
}))
};
}

View File

@@ -20,6 +20,7 @@ export const MVP_FIELDS = [
"TaperModulusArray",
"TaperWeightArray",
"TaperMTSArray",
"TaperGuidesCountArray",
"RodTypeArray",
"MoldedGuideFrictionRatio",
"WheeledGuideFrictionRatio",

View File

@@ -17,6 +17,10 @@ describe("solver-api", () => {
expect(response.status).toBe(200);
expect(response.body.runMetadata.source).toBe("base-case.xml");
expect(response.body.solver.pointCount).toBe(200);
expect(response.body.solver.profiles).toBeNull();
expect(response.body.solver.diagnostics).toBeNull();
expect(response.body.fieldTraceability?.schemaVersion).toBe(2);
expect(Array.isArray(response.body.fieldTraceability?.fields)).toBe(true);
});
it("returns fea prototype result and comparison payload", async () => {
@@ -70,7 +74,7 @@ describe("solver-api", () => {
expect(response.status).toBe(200);
expect(response.body.solver.pointCount).toBe(200);
expect(response.body.parsed.model.wellName).toContain("191/01-27-007-09W2/00");
expect(response.body.parsed.model.wellName).toBe("PLACEHOLDER-WELL");
expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true);
});
@@ -99,6 +103,7 @@ describe("solver-api", () => {
expect(response.body.unsupportedFields.sort()).toEqual(
defaultResp.body.unsupportedFields.sort()
);
expect(response.body.fieldTraceability?.fields?.length).toBeGreaterThan(0);
});
it("rejects empty body on POST /case/parse", async () => {

View File

@@ -0,0 +1,44 @@
import fs from "node:fs";
import path from "node:path";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../");
const xml = fs.readFileSync(path.join(ROOT, "data/cases/base-case.xml"), "utf-8");
describe("quality gates", () => {
it("cross-model comparison has finite residuals when both solvers run", async () => {
const app = buildApp();
const response = await request(app).post("/solve").send({ xml, solverModel: "both" });
expect(response.status).toBe(200);
const rms = response.body.comparison.residualSummary.rms;
expect(Number.isFinite(rms)).toBe(true);
expect(rms).toBeGreaterThan(0);
expect(response.body.comparison.pointwiseResiduals.series.length).toBe(
response.body.comparison.pointwiseResiduals.points
);
});
it("perturbing rod friction changes peak polished load (field sensitivity)", async () => {
const app = buildApp();
const xmlHi = xml.replace(
"<RodFrictionCoefficient>0.2</RodFrictionCoefficient>",
"<RodFrictionCoefficient>0.28</RodFrictionCoefficient>"
);
const base = await request(app).post("/solve").send({ xml, solverModel: "fdm" });
const perturbed = await request(app).post("/solve").send({ xml: xmlHi, solverModel: "fdm" });
expect(base.status).toBe(200);
expect(perturbed.status).toBe(200);
expect(perturbed.body.solver.maxPolishedLoad).not.toBe(base.body.solver.maxPolishedLoad);
});
it("includes fieldTraceability on POST /solve", async () => {
const app = buildApp();
const response = await request(app).post("/solve").send({ xml });
expect(response.status).toBe(200);
expect(response.body.fieldTraceability?.schemaVersion).toBe(2);
const pumpDepth = response.body.fieldTraceability.fields.find((f) => f.xmlKey === "PumpDepth");
expect(pumpDepth?.category).toBe("physics");
});
});