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:
25
solver-api/package-lock.json
generated
25
solver-api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
76
solver-api/src/fieldTraceability.js
Normal file
76
solver-api/src/fieldTraceability.js
Normal 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] ?? ""
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const MVP_FIELDS = [
|
||||
"TaperModulusArray",
|
||||
"TaperWeightArray",
|
||||
"TaperMTSArray",
|
||||
"TaperGuidesCountArray",
|
||||
"RodTypeArray",
|
||||
"MoldedGuideFrictionRatio",
|
||||
"WheeledGuideFrictionRatio",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
44
solver-api/tests/quality.test.js
Normal file
44
solver-api/tests/quality.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user