Initial commit: establish deterministic rod-string solver stack.
Set up the C solver core, Node API orchestration, TS GUI workflow, and engineering documentation with cleaned repo hygiene for private Git hosting. Made-with: Cursor
This commit is contained in:
365
solver-api/src/app.js
Normal file
365
solver-api/src/app.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { parseCaseXml } from "./xmlParser.js";
|
||||
import { runSolver, deriveTrajectoryFrictionMultiplier } from "./solverClient.js";
|
||||
import { validateSurfaceCard } from "./cardQa.js";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../");
|
||||
const DEFAULT_XML = path.join(ROOT, "data/cases/base-case.xml");
|
||||
const SOLVER_BINARY_FDM = path.join(ROOT, "solver-c/solver_main");
|
||||
const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_main");
|
||||
|
||||
function resolveSolverModel(raw) {
|
||||
const model = (raw || "fdm").toLowerCase();
|
||||
if (model !== "fdm" && model !== "fea" && model !== "both") {
|
||||
throw new Error("solverModel must be 'fdm', 'fea', or 'both'");
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function resolveWorkflow(raw) {
|
||||
const workflow = (raw || "predictive").toLowerCase();
|
||||
if (workflow !== "predictive" && workflow !== "diagnostic") {
|
||||
throw new Error("workflow must be 'predictive' or 'diagnostic'");
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function residualSeries(aCard, bCard) {
|
||||
const n = Math.min(aCard.length, bCard.length);
|
||||
let sumSq = 0;
|
||||
const series = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const dp = aCard[i].polishedLoad - bCard[i].polishedLoad;
|
||||
const dd = aCard[i].downholeLoad - bCard[i].downholeLoad;
|
||||
sumSq += dp * dp + dd * dd;
|
||||
series.push({
|
||||
position: aCard[i].position,
|
||||
polishedLoadResidual: dp,
|
||||
downholeLoadResidual: dd
|
||||
});
|
||||
}
|
||||
return {
|
||||
points: n,
|
||||
rms: Math.sqrt(sumSq / Math.max(2 * n, 1)),
|
||||
series
|
||||
};
|
||||
}
|
||||
|
||||
function buildRodStringVerbose(model) {
|
||||
const lens = model.taperLengthM || [];
|
||||
const hasTaper = lens.some((v) => Number.isFinite(v) && v > 0);
|
||||
return {
|
||||
hasTaper,
|
||||
taperSectionsUsed: lens.filter((v) => Number.isFinite(v) && v > 0).length
|
||||
};
|
||||
}
|
||||
|
||||
function createComparisonSummary(fdm, fea) {
|
||||
const res = residualSeries(fdm.card, fea.card);
|
||||
const polishedMaxDelta = fea.maxPolishedLoad - fdm.maxPolishedLoad;
|
||||
const polishedMinDelta = fea.minPolishedLoad - fdm.minPolishedLoad;
|
||||
const downholeMaxDelta = fea.maxDownholeLoad - fdm.maxDownholeLoad;
|
||||
const downholeMinDelta = fea.minDownholeLoad - fdm.minDownholeLoad;
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
peakLoadDeltas: {
|
||||
polishedMaxDelta,
|
||||
polishedMinDelta,
|
||||
downholeMaxDelta,
|
||||
downholeMinDelta
|
||||
},
|
||||
/* Backward-compatible flat fields for existing clients. */
|
||||
polishedMaxDelta,
|
||||
polishedMinDelta,
|
||||
downholeMaxDelta,
|
||||
downholeMinDelta,
|
||||
residualSummary: {
|
||||
points: res.points,
|
||||
rms: res.rms
|
||||
},
|
||||
pointwiseResiduals: {
|
||||
points: res.points,
|
||||
series: res.series
|
||||
},
|
||||
fourier: fdm.fourierBaseline || fea.fourierBaseline || null
|
||||
};
|
||||
}
|
||||
|
||||
async function runRequestedModels(solverModel, parsedModel, workflow, surfaceCard, options = {}) {
|
||||
const fdm = await runSolver(SOLVER_BINARY_FDM, parsedModel, workflow, surfaceCard, options);
|
||||
|
||||
if (solverModel === "fdm") {
|
||||
return {
|
||||
solver: fdm,
|
||||
solverModel: "fdm"
|
||||
};
|
||||
}
|
||||
|
||||
const fea = await runSolver(SOLVER_BINARY_FEA, parsedModel, workflow, surfaceCard, options);
|
||||
|
||||
if (solverModel === "fea") {
|
||||
return {
|
||||
solver: fea,
|
||||
solverModel: "fea",
|
||||
solvers: { fdm, fea },
|
||||
comparison: createComparisonSummary(fdm, fea)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
solver: fdm,
|
||||
solverModel: "both",
|
||||
solvers: { fdm, fea },
|
||||
comparison: createComparisonSummary(fdm, fea)
|
||||
};
|
||||
}
|
||||
|
||||
async function runRequestedModelsWithWorkflow(solverModel, workflow, parsedModel, surfaceCard, options = {}) {
|
||||
if (workflow === "predictive") {
|
||||
const runResults = await runRequestedModels(solverModel, parsedModel, workflow, null, options);
|
||||
return {
|
||||
...runResults,
|
||||
workflow,
|
||||
verbose: {
|
||||
workflow: "predictive",
|
||||
references: [
|
||||
"Gibbs damped-wave equation for rod-string dynamics",
|
||||
"Everitt & Jennings finite-difference transfer approach",
|
||||
"Dynamic finite element bar formulation (Eisner et al.)"
|
||||
],
|
||||
boundaryData: {
|
||||
type: "predicted_surface_motion",
|
||||
source: "virtual_well_input"
|
||||
},
|
||||
numerics: {
|
||||
schemaVersion: 2,
|
||||
units: "SI"
|
||||
},
|
||||
rodString: buildRodStringVerbose(parsedModel),
|
||||
trajectoryCoupling: {
|
||||
frictionMultiplier: deriveTrajectoryFrictionMultiplier(parsedModel)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const diag = await runRequestedModels(solverModel, parsedModel, workflow, surfaceCard, options);
|
||||
return {
|
||||
...diag,
|
||||
workflow,
|
||||
pumpMovement: {
|
||||
stroke: diag.solver.pumpMovement?.stroke ?? 0,
|
||||
position: diag.solver.pumpMovement?.position ?? [],
|
||||
velocity: diag.solver.pumpMovement?.velocity ?? [],
|
||||
periodSeconds: 60 / (parsedModel.pumpingSpeed || 5)
|
||||
},
|
||||
verbose: {
|
||||
workflow: "diagnostic",
|
||||
solverModel: diag.solverModel,
|
||||
references: [
|
||||
"Gibbs damped-wave equation framework",
|
||||
"Everitt & Jennings finite-difference diagnostic card computation",
|
||||
"Eisner et al. FEM diagnostic load iteration (FEA path)"
|
||||
],
|
||||
rodString: buildRodStringVerbose(parsedModel),
|
||||
trajectoryCoupling: {
|
||||
frictionMultiplier: deriveTrajectoryFrictionMultiplier(parsedModel)
|
||||
},
|
||||
numerics: {
|
||||
schemaVersion: 2,
|
||||
units: "SI",
|
||||
variableRodProperties: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (value === undefined) {
|
||||
return "null";
|
||||
}
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => stableStringify(v)).join(",")}]`;
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const props = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
|
||||
return `{${props.join(",")}}`;
|
||||
}
|
||||
|
||||
export function buildApp() {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "4mb" }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get("/case/default", async (_req, res) => {
|
||||
try {
|
||||
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
|
||||
const parsed = await parseCaseXml(xml);
|
||||
res.json(parsed);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: String(error.message || error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/case/parse", async (req, res) => {
|
||||
try {
|
||||
const xml = req.body?.xml;
|
||||
if (typeof xml !== "string" || !xml.trim()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Request body must include xml string", schemaVersion: 2 });
|
||||
}
|
||||
const parsed = await parseCaseXml(xml);
|
||||
return res.json({ ...parsed, schemaVersion: 2 });
|
||||
} catch (error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/solve/validate-card", (req, res) => {
|
||||
try {
|
||||
const surfaceCard = req.body?.surfaceCard;
|
||||
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
|
||||
return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/solve", async (req, res) => {
|
||||
try {
|
||||
const xml = req.body?.xml;
|
||||
if (!xml || typeof xml !== "string") {
|
||||
return res.status(400).json({ error: "Request body must include xml string" });
|
||||
}
|
||||
const solverModel = resolveSolverModel(req.body?.solverModel);
|
||||
const workflow = resolveWorkflow(req.body?.workflow);
|
||||
const parsed = await parseCaseXml(xml);
|
||||
let surfaceCardQa = null;
|
||||
if (workflow === "diagnostic") {
|
||||
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
|
||||
}
|
||||
const runResults = await runRequestedModelsWithWorkflow(
|
||||
solverModel,
|
||||
workflow,
|
||||
parsed.model,
|
||||
req.body?.surfaceCard,
|
||||
req.body?.options || {}
|
||||
);
|
||||
|
||||
const runMetadata = {
|
||||
deterministic: true,
|
||||
pointCount: runResults.solver.pointCount,
|
||||
generatedAt: new Date().toISOString(),
|
||||
solverModel: runResults.solverModel,
|
||||
workflow,
|
||||
schemaVersion: 2,
|
||||
units: "SI"
|
||||
};
|
||||
|
||||
const fingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
solver: runResults.solver,
|
||||
solvers: runResults.solvers,
|
||||
comparison: runResults.comparison,
|
||||
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement,
|
||||
verbose: runResults.verbose
|
||||
})
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
return res.json({
|
||||
schemaVersion: 2,
|
||||
units: "SI",
|
||||
parsed,
|
||||
parseWarnings: parsed.warnings,
|
||||
surfaceCardQa,
|
||||
solver: runResults.solver,
|
||||
solvers: runResults.solvers,
|
||||
comparison: runResults.comparison,
|
||||
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement ?? null,
|
||||
verbose: runResults.verbose,
|
||||
runMetadata,
|
||||
fingerprint
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/solve/default", async (_req, res) => {
|
||||
try {
|
||||
const solverModel = resolveSolverModel(_req.query?.solverModel);
|
||||
const workflow = resolveWorkflow(_req.query?.workflow);
|
||||
if (workflow === "diagnostic") {
|
||||
return res.status(400).json({
|
||||
error: "diagnostic workflow requires POST /solve with surfaceCard data",
|
||||
schemaVersion: 2
|
||||
});
|
||||
}
|
||||
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
|
||||
const parsed = await parseCaseXml(xml);
|
||||
const runResults = await runRequestedModelsWithWorkflow(
|
||||
solverModel,
|
||||
workflow,
|
||||
parsed.model,
|
||||
null,
|
||||
_req.body?.options || {}
|
||||
);
|
||||
const runMetadata = {
|
||||
deterministic: true,
|
||||
pointCount: runResults.solver.pointCount,
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: "base-case.xml",
|
||||
solverModel: runResults.solverModel,
|
||||
workflow,
|
||||
schemaVersion: 2,
|
||||
units: "SI"
|
||||
};
|
||||
const fingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
solver: runResults.solver,
|
||||
solvers: runResults.solvers,
|
||||
comparison: runResults.comparison,
|
||||
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement,
|
||||
verbose: runResults.verbose
|
||||
})
|
||||
)
|
||||
.digest("hex");
|
||||
return res.json({
|
||||
schemaVersion: 2,
|
||||
units: "SI",
|
||||
parsed,
|
||||
parseWarnings: parsed.warnings,
|
||||
solver: runResults.solver,
|
||||
solvers: runResults.solvers,
|
||||
comparison: runResults.comparison,
|
||||
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement ?? null,
|
||||
verbose: runResults.verbose,
|
||||
runMetadata,
|
||||
fingerprint
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
68
solver-api/src/cardQa.js
Normal file
68
solver-api/src/cardQa.js
Normal file
@@ -0,0 +1,68 @@
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
}
|
||||
|
||||
export function validateSurfaceCard(surfaceCard, options = {}) {
|
||||
const minSamples = options.minSamples ?? 75;
|
||||
if (!surfaceCard || !Array.isArray(surfaceCard.position) || !Array.isArray(surfaceCard.load)) {
|
||||
throw new Error("surfaceCard.position and surfaceCard.load arrays are required");
|
||||
}
|
||||
if (surfaceCard.position.length !== surfaceCard.load.length) {
|
||||
throw new Error("surfaceCard.position and surfaceCard.load must have equal length");
|
||||
}
|
||||
if (surfaceCard.position.length < minSamples) {
|
||||
throw new Error(`surfaceCard must contain at least ${minSamples} samples (Eisner guidance)`);
|
||||
}
|
||||
if (surfaceCard.time && surfaceCard.time.length !== surfaceCard.position.length) {
|
||||
throw new Error("surfaceCard.time length must match position length when provided");
|
||||
}
|
||||
|
||||
const n = surfaceCard.position.length;
|
||||
let dt = null;
|
||||
if (Array.isArray(surfaceCard.time) && surfaceCard.time.length === n) {
|
||||
const dts = [];
|
||||
for (let i = 1; i < n; i += 1) {
|
||||
dts.push(surfaceCard.time[i] - surfaceCard.time[i - 1]);
|
||||
}
|
||||
const spread = Math.max(...dts) - Math.min(...dts);
|
||||
if (spread > 1e-6) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [`non-uniform dt (spread=${spread.toExponential(3)})`]
|
||||
};
|
||||
}
|
||||
dt = mean(dts);
|
||||
}
|
||||
|
||||
const pos = surfaceCard.position;
|
||||
const load = surfaceCard.load;
|
||||
const cycleClosure = Math.abs(pos[n - 1] - pos[0]);
|
||||
const issues = [];
|
||||
if (cycleClosure > (options.cycleTol ?? 0.05)) {
|
||||
issues.push(`cycle not closed: |pos[last]-pos[0]|=${cycleClosure.toFixed(4)}`);
|
||||
}
|
||||
|
||||
const med = median(load);
|
||||
const despiked = load.map((v) => {
|
||||
const dev = Math.abs(v - med);
|
||||
return dev > (options.spikeSigma ?? 6) * (Math.abs(med) + 1) ? med : v;
|
||||
});
|
||||
const spikes = load.reduce((acc, v, i) => acc + (despiked[i] !== v ? 1 : 0), 0);
|
||||
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
issues,
|
||||
samples: n,
|
||||
uniformDt: dt !== null,
|
||||
dt,
|
||||
cycleClosure,
|
||||
spikesReplaced: spikes,
|
||||
steadyStateCyclesToDiscard: options.discardCycles ?? 3
|
||||
};
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1);
|
||||
}
|
||||
50
solver-api/src/schema.js
Normal file
50
solver-api/src/schema.js
Normal file
@@ -0,0 +1,50 @@
|
||||
export const MVP_FIELDS = [
|
||||
"WellName",
|
||||
"Company",
|
||||
"PumpingSpeed",
|
||||
"PumpDepth",
|
||||
"TubingAnchorLocation",
|
||||
"RodFrictionCoefficient",
|
||||
"StuffingBoxFriction",
|
||||
"PumpFriction",
|
||||
"WaterCut",
|
||||
"MeasuredDepthArray",
|
||||
"InclinationFromVerticalArray",
|
||||
"AzimuthFromNorthArray",
|
||||
"UnitsSelection",
|
||||
"UpStrokeDampingFactor",
|
||||
"DownStrokeDampingFactor",
|
||||
"NonDimensionalFluidDamping",
|
||||
"TaperDiameterArray",
|
||||
"TaperLengthArray",
|
||||
"TaperModulusArray",
|
||||
"TaperWeightArray",
|
||||
"TaperMTSArray",
|
||||
"RodTypeArray",
|
||||
"MoldedGuideFrictionRatio",
|
||||
"WheeledGuideFrictionRatio",
|
||||
"OtherGuideFrictionRatio",
|
||||
"PumpDiameter",
|
||||
"PumpIntakePressure",
|
||||
"TubingSize",
|
||||
"TubingGradient",
|
||||
"PumpFillageOption",
|
||||
"PercentPumpFillage",
|
||||
"PercentageUpstrokeTime",
|
||||
"PercentageDownstrokeTime",
|
||||
"PumpingUnitID",
|
||||
"PumpingSpeedOption",
|
||||
"FluidLevelOilGravity",
|
||||
"WaterSpecGravity",
|
||||
"RodGuideTypeArray",
|
||||
"RodGuideWeightArray",
|
||||
"SinkerBarDiameter",
|
||||
"SinkerBarLength"
|
||||
];
|
||||
|
||||
export const REQUIRED_FIELDS = [
|
||||
"PumpingSpeed",
|
||||
"PumpDepth",
|
||||
"MeasuredDepthArray",
|
||||
"InclinationFromVerticalArray"
|
||||
];
|
||||
8
solver-api/src/server.js
Normal file
8
solver-api/src/server.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const app = buildApp();
|
||||
const port = process.env.PORT || 4400;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`solver-api listening on ${port}`);
|
||||
});
|
||||
303
solver-api/src/solverClient.js
Normal file
303
solver-api/src/solverClient.js
Normal file
@@ -0,0 +1,303 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { exec as execCommand } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const SOLVER_STDIO_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const execAsync = promisify(execCommand);
|
||||
|
||||
const STEEL_E = 2.05e11;
|
||||
const STEEL_RHO = 7850;
|
||||
const FIBERGLASS_E = 5.5e9; /* order-of-magnitude; may be overridden by modulus array */
|
||||
const FIBERGLASS_RHO = 1900;
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1);
|
||||
}
|
||||
|
||||
function deriveTaperFactor(model) {
|
||||
const diam = Array.isArray(model.taperDiameterM) ? model.taperDiameterM.filter((v) => Number.isFinite(v) && v > 0) : [];
|
||||
const lens = Array.isArray(model.taperLengthM) ? model.taperLengthM.filter((v) => Number.isFinite(v) && v > 0) : [];
|
||||
if (diam.length === 0 || lens.length === 0) return 1.0;
|
||||
const n = Math.min(diam.length, lens.length);
|
||||
const weightedDiameter = diam.slice(0, n).reduce((acc, d, i) => acc + d * lens[i], 0);
|
||||
const totalLength = lens.slice(0, n).reduce((a, b) => a + b, 0);
|
||||
if (totalLength <= 0) return 1.0;
|
||||
const dAvg = weightedDiameter / totalLength;
|
||||
const area = Math.PI * dAvg * dAvg * 0.25;
|
||||
const refArea = Math.PI * 0.019 * 0.019 * 0.25;
|
||||
return clamp(area / refArea, 0.65, 1.25);
|
||||
}
|
||||
|
||||
export function deriveTrajectoryFrictionMultiplier(model) {
|
||||
const md = Array.isArray(model.measuredDepthM) ? model.measuredDepthM : [];
|
||||
const inc = Array.isArray(model.inclinationRad) ? model.inclinationRad : [];
|
||||
const azi = Array.isArray(model.azimuthRad) ? model.azimuthRad : [];
|
||||
if (md.length < 3 || inc.length !== md.length || azi.length !== md.length) return 1.0;
|
||||
const kappas = [];
|
||||
for (let i = 1; i < md.length; i += 1) {
|
||||
const ds = Math.max(md[i] - md[i - 1], 1e-6);
|
||||
const dInc = inc[i] - inc[i - 1];
|
||||
const dAzi = azi[i] - azi[i - 1];
|
||||
const incMid = (inc[i] + inc[i - 1]) * 0.5;
|
||||
const kappa = Math.sqrt(dInc * dInc + (Math.sin(incMid) * dAzi) ** 2) / ds;
|
||||
kappas.push(kappa);
|
||||
}
|
||||
const kMean = mean(kappas);
|
||||
return 1 + clamp(kMean * 400, 0, 0.8);
|
||||
}
|
||||
|
||||
function rodTypeToProps(typeCode) {
|
||||
const t = Math.round(typeCode);
|
||||
if (t === 2) {
|
||||
return { E: FIBERGLASS_E, rho: FIBERGLASS_RHO };
|
||||
}
|
||||
return { E: STEEL_E, rho: STEEL_RHO };
|
||||
}
|
||||
|
||||
function buildRodNodes(model) {
|
||||
const nx = 48;
|
||||
const nodes = nx + 1;
|
||||
const depthM = model.pumpDepthM ?? model.pumpDepth;
|
||||
const anchorM = model.tubingAnchorLocationM ?? model.tubingAnchorLocation;
|
||||
const rodLength = clamp(depthM - anchorM, 250 * 0.3048, 3500 * 0.3048);
|
||||
const defaultD = 0.019;
|
||||
const defaultE = STEEL_E;
|
||||
const defaultRho = STEEL_RHO;
|
||||
|
||||
const lens = (model.taperLengthM || []).filter((v) => v > 0);
|
||||
const dM = (model.taperDiameterM || []).filter((v) => v > 0);
|
||||
const ePa = model.taperModulusPa || [];
|
||||
const weightNPerM = model.taperWeightNPerM || [];
|
||||
const mtsN = model.taperMtsN || [];
|
||||
const types = model.rodType || [];
|
||||
const guideW = model.rodGuideWeightNPerM || [];
|
||||
|
||||
const areaByNode = new Array(nodes).fill(Math.PI * defaultD * defaultD * 0.25);
|
||||
const modulusByNode = new Array(nodes).fill(defaultE);
|
||||
const densityByNode = new Array(nodes).fill(defaultRho);
|
||||
const weightByNode = new Array(nodes).fill(defaultRho * 9.80665 * (Math.PI * defaultD * defaultD * 0.25));
|
||||
const mtsByNode = new Array(nodes).fill(8e5);
|
||||
const guideWeightByNode = new Array(nodes).fill(0);
|
||||
|
||||
if (lens.length === 0 || dM.length === 0) {
|
||||
return {
|
||||
has_variable_rod: 0,
|
||||
rod_node_count: nodes,
|
||||
area_m2: areaByNode,
|
||||
modulus_pa: modulusByNode,
|
||||
density_kg_m3: densityByNode,
|
||||
rod_length_m: rodLength,
|
||||
nx
|
||||
};
|
||||
}
|
||||
|
||||
const totalLen = lens.reduce((a, b) => a + b, 0);
|
||||
const scale = totalLen > 0 ? rodLength / totalLen : 1;
|
||||
let covered = 0;
|
||||
let idx = 0;
|
||||
const segmentLength = rodLength / nx;
|
||||
for (let i = 0; i < nodes; i += 1) {
|
||||
const s = i * segmentLength;
|
||||
while (idx < lens.length - 1 && s > covered + lens[idx] * scale) {
|
||||
covered += lens[idx] * scale;
|
||||
idx += 1;
|
||||
}
|
||||
const d = dM[Math.min(idx, dM.length - 1)];
|
||||
const area = Math.PI * clamp(d, 0.008, 0.05) ** 2 * 0.25;
|
||||
areaByNode[i] = area;
|
||||
const eFromXml = ePa[Math.min(idx, ePa.length - 1)];
|
||||
const wFromXml = weightNPerM[Math.min(idx, weightNPerM.length - 1)];
|
||||
const mtsFromXml = mtsN[Math.min(idx, mtsN.length - 1)];
|
||||
const gFromXml = guideW[Math.min(idx, guideW.length - 1)];
|
||||
const typeCode = types[Math.min(idx, types.length - 1)] ?? 3;
|
||||
const props = rodTypeToProps(typeCode);
|
||||
modulusByNode[i] = eFromXml && eFromXml > 1e8 ? eFromXml : props.E;
|
||||
densityByNode[i] = props.rho;
|
||||
weightByNode[i] = wFromXml && wFromXml > 0 ? wFromXml : props.rho * 9.80665 * area;
|
||||
mtsByNode[i] = mtsFromXml && mtsFromXml > 0 ? mtsFromXml : 8e5;
|
||||
guideWeightByNode[i] = gFromXml && gFromXml > 0 ? gFromXml : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
has_variable_rod: 1,
|
||||
rod_node_count: nodes,
|
||||
area_m2: areaByNode,
|
||||
modulus_pa: modulusByNode,
|
||||
density_kg_m3: densityByNode,
|
||||
weight_n_per_m: weightByNode,
|
||||
mts_n: mtsByNode,
|
||||
guide_weight_n_per_m: guideWeightByNode,
|
||||
rod_length_m: rodLength,
|
||||
nx
|
||||
};
|
||||
}
|
||||
|
||||
function buildSolverPayload(model, workflow, surfaceCard, options = {}) {
|
||||
const rod = buildRodNodes(model);
|
||||
const taperFactor = deriveTaperFactor(model);
|
||||
const trajMul = deriveTrajectoryFrictionMultiplier(model);
|
||||
const surveyMd = model.measuredDepthM || [];
|
||||
const surveyInc = model.inclinationRad || [];
|
||||
const surveyAzi = model.azimuthRad || [];
|
||||
|
||||
const payload = {
|
||||
schemaVersion: 2,
|
||||
workflow,
|
||||
options: {
|
||||
enableProfiles: Boolean(options.enableProfiles),
|
||||
enableDiagnosticsDetail: Boolean(options.enableDiagnosticsDetail),
|
||||
enableFourierBaseline: Boolean(options.enableFourierBaseline),
|
||||
fourierHarmonics: Number.isFinite(options.fourierHarmonics) ? options.fourierHarmonics : 8
|
||||
},
|
||||
model: {
|
||||
pumping_speed: model.pumpingSpeed,
|
||||
pump_depth: model.pumpDepthM ?? model.pumpDepth,
|
||||
tubing_anchor_location: model.tubingAnchorLocationM ?? model.tubingAnchorLocation,
|
||||
rod_friction_coefficient: model.rodFrictionCoefficient,
|
||||
stuffing_box_friction: model.stuffingBoxFrictionN ?? model.stuffingBoxFriction,
|
||||
pump_friction: model.pumpFrictionN ?? model.pumpFriction,
|
||||
taper_factor: taperFactor,
|
||||
trajectory_friction_multiplier: trajMul,
|
||||
fluid_density_kg_m3: model.fluidDensityKgM3 ?? 1000,
|
||||
gravity: 9.80665,
|
||||
upstroke_damping: model.upStrokeDamping ?? 0,
|
||||
downstroke_damping: model.downStrokeDamping ?? 0,
|
||||
non_dim_damping: model.nonDimensionalFluidDamping ?? 0,
|
||||
molded_guide_mu_scale: model.moldedGuideFrictionRatio ?? 1,
|
||||
wheeled_guide_mu_scale: model.wheeledGuideFrictionRatio ?? 1,
|
||||
other_guide_mu_scale: model.otherGuideFrictionRatio ?? 1,
|
||||
has_variable_rod: rod.has_variable_rod,
|
||||
rod_node_count: rod.rod_node_count,
|
||||
area_m2: rod.area_m2,
|
||||
modulus_pa: rod.modulus_pa,
|
||||
density_kg_m3: rod.density_kg_m3,
|
||||
weight_n_per_m: rod.weight_n_per_m,
|
||||
mts_n: rod.mts_n,
|
||||
guide_weight_n_per_m: rod.guide_weight_n_per_m,
|
||||
survey_md_m: surveyMd,
|
||||
survey_inc_rad: surveyInc,
|
||||
survey_azi_rad: surveyAzi,
|
||||
pump_diameter_m: model.pumpDiameterM ?? (model.pumpDiameter > 2 ? model.pumpDiameter * 0.0254 : model.pumpDiameter),
|
||||
pump_intake_pressure_pa: model.pumpIntakePressurePa ?? 0,
|
||||
tubing_id_m: model.tubingInnerDiameterM ?? 0.0762,
|
||||
percent_upstroke_time: model.percentUpstrokeTime ?? 50,
|
||||
percent_downstroke_time: model.percentDownstrokeTime ?? 50,
|
||||
pump_fillage_option: model.pumpFillageOption ?? 0,
|
||||
percent_pump_fillage: model.percentPumpFillage ?? 0,
|
||||
sinker_bar_diameter_m: model.sinkerBarDiameterM ?? 0,
|
||||
sinker_bar_length_m: model.sinkerBarLengthM ?? 0
|
||||
}
|
||||
};
|
||||
|
||||
if (workflow === "diagnostic" && surfaceCard) {
|
||||
const pos = surfaceCard.position.map(Number);
|
||||
const load = surfaceCard.load.map(Number);
|
||||
const time = Array.isArray(surfaceCard.time) ? surfaceCard.time.map(Number) : null;
|
||||
payload.surfaceCard = {
|
||||
position_m: pos,
|
||||
load_n: load,
|
||||
time_s: time && time.length === pos.length ? time : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
async function ensureSolverBinary(solverBinaryPath, forceRebuild = false) {
|
||||
if (!forceRebuild) {
|
||||
try {
|
||||
await access(solverBinaryPath);
|
||||
return;
|
||||
} catch (_error) {
|
||||
/* Build on demand when missing. */
|
||||
}
|
||||
}
|
||||
const root = solverBinaryPath.replace(/\/solver-c\/[^/]+$/, "");
|
||||
const binaryName = solverBinaryPath.split("/").pop();
|
||||
const mainSource = binaryName === "solver_fea_main" ? "main_fea.c" : "main.c";
|
||||
const sources = [
|
||||
`${root}/solver-c/src/solver_common.c`,
|
||||
`${root}/solver-c/src/json_stdin.c`,
|
||||
`${root}/solver-c/src/trajectory.c`,
|
||||
`${root}/solver-c/src/solver_diagnostic.c`,
|
||||
`${root}/solver-c/src/solver.c`,
|
||||
`${root}/solver-c/src/solver_fea.c`,
|
||||
`${root}/solver-c/src/solver_fourier.c`
|
||||
].join(" ");
|
||||
const compileCommand = `gcc -std=c99 -I"${root}/solver-c/include" ${sources} "${root}/solver-c/src/${mainSource}" -lm -o "${solverBinaryPath}"`;
|
||||
await execAsync(compileCommand);
|
||||
}
|
||||
|
||||
function runSolverProcess(solverBinaryPath, jsonPayload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(solverBinaryPath, ["--stdin"], {
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let outBytes = 0;
|
||||
let errBytes = 0;
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
outBytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (outBytes > SOLVER_STDIO_MAX_BYTES) {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("solver stdout exceeded max buffer"));
|
||||
return;
|
||||
}
|
||||
stdout += chunk;
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
errBytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (errBytes > SOLVER_STDIO_MAX_BYTES) {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("solver stderr exceeded max buffer"));
|
||||
return;
|
||||
}
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.on("error", (error) => reject(error));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const detail = stderr.trim() || `solver exited with code ${code}`;
|
||||
reject(new Error(detail));
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
|
||||
child.stdin.write(jsonPayload);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSolver(solverBinaryPath, model, workflow = "predictive", surfaceCard = null, options = {}) {
|
||||
await ensureSolverBinary(solverBinaryPath);
|
||||
const wf = workflow === "diagnostic" ? "diagnostic" : "predictive";
|
||||
const jsonPayload = buildSolverPayload(model, wf, surfaceCard, options);
|
||||
|
||||
let stdout;
|
||||
try {
|
||||
stdout = await runSolverProcess(solverBinaryPath, jsonPayload);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
await ensureSolverBinary(solverBinaryPath, true);
|
||||
stdout = await runSolverProcess(solverBinaryPath, jsonPayload);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
186
solver-api/src/xmlParser.js
Normal file
186
solver-api/src/xmlParser.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import { MVP_FIELDS, REQUIRED_FIELDS } from "./schema.js";
|
||||
|
||||
const FT_TO_M = 0.3048;
|
||||
const IN_TO_M = 0.0254;
|
||||
const LBF_TO_N = 4.4482216152605;
|
||||
const PSI_TO_PA = 6894.757293168;
|
||||
const MPSI_TO_PA = 6.894757293168e9;
|
||||
|
||||
function parseArrayValue(raw) {
|
||||
return String(raw)
|
||||
.split(":")
|
||||
.filter(Boolean)
|
||||
.map((item) => Number(item));
|
||||
}
|
||||
|
||||
function parseNumeric(raw) {
|
||||
const value = Number(raw);
|
||||
if (Number.isNaN(value)) {
|
||||
throw new Error(`Invalid numeric value: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeToSi(model, rawFields, warnings) {
|
||||
const units = Number(rawFields.UnitsSelection ?? 0);
|
||||
const useImperialOilfield = units === 2 || units === 0; /* 0 treated like legacy imperial in many SROD exports */
|
||||
|
||||
if (units === 0) {
|
||||
warnings.push("UnitsSelection missing; assuming imperial oilfield conversions (heuristic)");
|
||||
}
|
||||
|
||||
if (!useImperialOilfield) {
|
||||
warnings.push(`UnitsSelection=${units}; treating numeric fields as SI (heuristic)`);
|
||||
model.pumpDepthM = model.pumpDepth;
|
||||
model.tubingAnchorLocationM = model.tubingAnchorLocation;
|
||||
model.measuredDepthM = [...model.measuredDepth];
|
||||
model.inclinationRad = model.inclination.map((v) => (v * Math.PI) / 180);
|
||||
model.azimuthRad = model.azimuth.map((v) => (v * Math.PI) / 180);
|
||||
model.stuffingBoxFrictionN = model.stuffingBoxFriction;
|
||||
model.pumpFrictionN = model.pumpFriction;
|
||||
model.taperLengthM = [...(model.taperLength || [])];
|
||||
model.taperDiameterM = [...(model.taperDiameter || [])];
|
||||
model.taperModulusPa = [...(model.taperModulus || [])];
|
||||
model.taperWeightNPerM = [...(model.taperWeight || [])];
|
||||
model.taperMtsN = [...(model.taperMts || [])];
|
||||
model.rodGuideWeightNPerM = [...(model.rodGuideWeight || [])];
|
||||
model.sinkerBarDiameterM = model.sinkerBarDiameter;
|
||||
model.sinkerBarLengthM = model.sinkerBarLength;
|
||||
model.pumpIntakePressurePa = model.pumpIntakePressure;
|
||||
model.tubingGradientPaM = model.tubingGradient;
|
||||
model.pumpDiameterM = model.pumpDiameter;
|
||||
model.tubingInnerDiameterM = model.tubingSize > 0 ? model.tubingSize * IN_TO_M : 0.0762;
|
||||
model.fluidDensityKgM3 = computeFluidDensityKgM3(model);
|
||||
return;
|
||||
}
|
||||
|
||||
model.pumpDepthM = model.pumpDepth * FT_TO_M;
|
||||
model.tubingAnchorLocationM = model.tubingAnchorLocation * FT_TO_M;
|
||||
model.measuredDepthM = model.measuredDepth.map((v) => v * FT_TO_M);
|
||||
model.inclinationRad = model.inclination.map((v) => (v * Math.PI) / 180);
|
||||
model.azimuthRad = model.azimuth.map((v) => (v * Math.PI) / 180);
|
||||
model.stuffingBoxFrictionN = model.stuffingBoxFriction * LBF_TO_N;
|
||||
model.pumpFrictionN = model.pumpFriction * LBF_TO_N;
|
||||
|
||||
model.taperLengthM = (model.taperLength || []).map((v) => v * FT_TO_M);
|
||||
model.taperDiameterM = (model.taperDiameter || []).map((d) => (d > 2 ? d * IN_TO_M : d));
|
||||
model.taperModulusPa = (model.taperModulus || []).map((e) => (e > 1e8 ? e : e * MPSI_TO_PA));
|
||||
model.taperWeightNPerM = (model.taperWeight || []).map((w) => w * LBF_TO_N / FT_TO_M);
|
||||
model.taperMtsN = (model.taperMts || []).map((v) => v * LBF_TO_N);
|
||||
model.rodGuideWeightNPerM = (model.rodGuideWeight || []).map((w) => w * LBF_TO_N / FT_TO_M);
|
||||
model.sinkerBarDiameterM = model.sinkerBarDiameter > 2 ? model.sinkerBarDiameter * IN_TO_M : model.sinkerBarDiameter;
|
||||
model.sinkerBarLengthM = model.sinkerBarLength * FT_TO_M;
|
||||
|
||||
model.pumpIntakePressurePa = model.pumpIntakePressure * PSI_TO_PA;
|
||||
model.tubingGradientPaM = model.tubingGradient * PSI_TO_PA / FT_TO_M;
|
||||
|
||||
model.pumpDiameterM = model.pumpDiameter > 2 ? model.pumpDiameter * IN_TO_M : model.pumpDiameter;
|
||||
/* TubingSize in base-case is nominal inches code; keep raw for now */
|
||||
model.tubingInnerDiameterM = model.tubingSize > 0 ? model.tubingSize * IN_TO_M : 0.0762;
|
||||
|
||||
model.fluidDensityKgM3 = computeFluidDensityKgM3(model);
|
||||
}
|
||||
|
||||
function computeFluidDensityKgM3(model) {
|
||||
const wc = Math.max(0, Math.min(100, model.waterCut)) / 100;
|
||||
const rhoW = 1000 * (model.waterSpecGravity || 1.0);
|
||||
const api = model.fluidLevelOilGravity || 35;
|
||||
const rhoOil = 141.5 / (api + 131.5) * 999.012; /* simplified */
|
||||
const rho = wc * rhoW + (1 - wc) * rhoOil;
|
||||
if (!Number.isFinite(rho) || rho <= 0) {
|
||||
return 1000;
|
||||
}
|
||||
return rho;
|
||||
}
|
||||
|
||||
export async function parseCaseXml(xmlContent) {
|
||||
const parsed = await parseStringPromise(xmlContent, {
|
||||
explicitArray: false,
|
||||
explicitRoot: true,
|
||||
trim: true,
|
||||
mergeAttrs: false
|
||||
});
|
||||
|
||||
const caseNode = parsed?.INPRoot?.Case;
|
||||
if (!caseNode) {
|
||||
throw new Error("Missing INPRoot/Case node");
|
||||
}
|
||||
|
||||
const rawFields = {};
|
||||
for (const [key, value] of Object.entries(caseNode)) {
|
||||
if (key === "$") continue;
|
||||
rawFields[key] = value;
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
|
||||
const model = {
|
||||
wellName: rawFields.WellName || "Unknown",
|
||||
company: rawFields.Company || "Unknown",
|
||||
pumpingSpeed: parseNumeric(rawFields.PumpingSpeed ?? 0),
|
||||
pumpDepth: parseNumeric(rawFields.PumpDepth ?? 0),
|
||||
tubingAnchorLocation: parseNumeric(rawFields.TubingAnchorLocation ?? 0),
|
||||
rodFrictionCoefficient: parseNumeric(rawFields.RodFrictionCoefficient ?? 0),
|
||||
stuffingBoxFriction: parseNumeric(rawFields.StuffingBoxFriction ?? 0),
|
||||
pumpFriction: parseNumeric(rawFields.PumpFriction ?? 0),
|
||||
waterCut: parseNumeric(rawFields.WaterCut ?? 0),
|
||||
waterSpecGravity: parseNumeric(rawFields.WaterSpecGravity ?? 1.0),
|
||||
fluidLevelOilGravity: parseNumeric(rawFields.FluidLevelOilGravity ?? 0),
|
||||
measuredDepth: parseArrayValue(rawFields.MeasuredDepthArray ?? ""),
|
||||
inclination: parseArrayValue(rawFields.InclinationFromVerticalArray ?? ""),
|
||||
azimuth: parseArrayValue(rawFields.AzimuthFromNorthArray ?? ""),
|
||||
taperDiameter: parseArrayValue(rawFields.TaperDiameterArray ?? ""),
|
||||
taperLength: parseArrayValue(rawFields.TaperLengthArray ?? ""),
|
||||
taperModulus: parseArrayValue(rawFields.TaperModulusArray ?? ""),
|
||||
taperWeight: parseArrayValue(rawFields.TaperWeightArray ?? ""),
|
||||
taperMts: parseArrayValue(rawFields.TaperMTSArray ?? ""),
|
||||
rodType: parseArrayValue(rawFields.RodTypeArray ?? ""),
|
||||
rodGuideType: String(rawFields.RodGuideTypeArray ?? "")
|
||||
.split(":")
|
||||
.filter(Boolean),
|
||||
rodGuideWeight: parseArrayValue(rawFields.RodGuideWeightArray ?? ""),
|
||||
tubingSize: parseNumeric(rawFields.TubingSize ?? 0),
|
||||
unitsSelection: parseNumeric(rawFields.UnitsSelection ?? 0),
|
||||
upStrokeDamping: parseNumeric(rawFields.UpStrokeDampingFactor ?? 0),
|
||||
downStrokeDamping: parseNumeric(rawFields.DownStrokeDampingFactor ?? 0),
|
||||
nonDimensionalFluidDamping: parseNumeric(rawFields.NonDimensionalFluidDamping ?? 0),
|
||||
moldedGuideFrictionRatio: parseNumeric(rawFields.MoldedGuideFrictionRatio ?? 1.0),
|
||||
wheeledGuideFrictionRatio: parseNumeric(rawFields.WheeledGuideFrictionRatio ?? 1.0),
|
||||
otherGuideFrictionRatio: parseNumeric(rawFields.OtherGuideFrictionRatio ?? 1.0),
|
||||
pumpDiameter: parseNumeric(rawFields.PumpDiameter ?? 0),
|
||||
pumpIntakePressure: parseNumeric(rawFields.PumpIntakePressure ?? 0),
|
||||
tubingGradient: parseNumeric(rawFields.TubingGradient ?? 0),
|
||||
pumpFillageOption: parseNumeric(rawFields.PumpFillageOption ?? 0),
|
||||
percentPumpFillage: parseNumeric(rawFields.PercentPumpFillage ?? 0),
|
||||
percentUpstrokeTime: parseNumeric(rawFields.PercentageUpstrokeTime ?? 50),
|
||||
percentDownstrokeTime: parseNumeric(rawFields.PercentageDownstrokeTime ?? 50),
|
||||
pumpingUnitId: rawFields.PumpingUnitID || "",
|
||||
pumpingSpeedOption: parseNumeric(rawFields.PumpingSpeedOption ?? 0),
|
||||
sinkerBarDiameter: parseNumeric(rawFields.SinkerBarDiameter ?? 0),
|
||||
sinkerBarLength: parseNumeric(rawFields.SinkerBarLength ?? 0)
|
||||
};
|
||||
|
||||
const missingRequired = REQUIRED_FIELDS.filter((field) => !rawFields[field]);
|
||||
if (missingRequired.length > 0) {
|
||||
throw new Error(`Missing required field(s): ${missingRequired.join(", ")}`);
|
||||
}
|
||||
|
||||
if (
|
||||
model.measuredDepth.length !== model.inclination.length ||
|
||||
model.measuredDepth.length !== model.azimuth.length
|
||||
) {
|
||||
throw new Error("Trajectory arrays must have matching lengths");
|
||||
}
|
||||
|
||||
normalizeToSi(model, rawFields, warnings);
|
||||
|
||||
const unsupportedFields = Object.keys(rawFields).filter((field) => !MVP_FIELDS.includes(field));
|
||||
|
||||
return {
|
||||
model,
|
||||
unsupportedFields,
|
||||
rawFields,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user