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:
2026-04-16 21:59:42 -06:00
commit 725a72a773
83 changed files with 14687 additions and 0 deletions

365
solver-api/src/app.js Normal file
View 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
View 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
View 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
View 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}`);
});

View 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
View 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
};
}