fix: normalize engineering checks across units

Convert pump-depth vs rod-length mismatch gating to meter-equivalent comparisons, add imperial coverage, and correct rod material mapping with exported helper tests while refreshing related GUI/docs copy.

Made-with: Cursor
This commit is contained in:
2026-04-16 23:28:00 -06:00
parent 64e9d31373
commit 2a6cee21f8
8 changed files with 73 additions and 19 deletions

View File

@@ -109,8 +109,7 @@ make smoke
| **Pumping-unit kinematics** (Svinos tables, crank motion from `PumpingUnitID`) | Harmonic default; unit geometry unused |
| **Inverse calibration** | Fit damping / friction to measured downhole card |
| **Fourier** analytical diagnostic | Optional `comparison.fourier` |
| **GUI** 3D survey + layout | **Partial done** — Results tab includes 3D projected wellbore + rod/pump overlay + DLS contour; not a full 3D engine (no camera controls / mesh terrain yet) |
| **GUI diagnostic workflow** | Tabbed UI ships predictive solve end-to-end; diagnostic requires surface-card upload path in Kinematics (calls `POST /solve/validate-card` + `POST /solve` with `workflow=diagnostic`) — not wired in this pass |
| **GUI** 3D survey + layout | **Partial done** — Results tab includes 3D projected wellbore + rod/pump overlay, depth-hue and side-load overlays; not a full 3D engine (no camera controls / mesh terrain yet) |
| **GUI Pump / Fluid / Kinematics first-class mapping** | Tabs render editable fields but rely on `rawFields` round-trip rather than dedicated serializer logic; audit once solver-api adds explicit fields for `PumpFillageOption`, pumping-unit kinematics, etc. |
| **GUI fatigue / API RP 11BR table** | Backend does not emit a fatigue payload yet; surface in Results tab when `solver.fatigue` exists |
@@ -121,15 +120,12 @@ make smoke
- Fixed engineering gate in Solver tab:
- run blocked when `|PumpDepth - sum(TaperLengthArray)| > 15 m`.
- survey MD monotonicity and minimum station-count checks.
- Fixed DLS bad-section threshold:
- warnings + 3D contour use `15 deg/100` as the "bad section" limit.
- Results tab now shows:
- uPlot dynacard overlays,
- 3D projected wellbore with rod gradient and pump marker,
- interactive 3D view controls (rotate, pan, zoom, perspective/orthographic toggle, reset),
- highlighted bad-DLS segments,
- depth-hue and side-load risk overlays,
- segment highlight + clear-highlight controls,
- trajectory analytics table with row↔3D segment cross-highlight,
- side-load overlay mode (when `solver.profiles.sideLoadProfile` is available),
- pump-placement diagnostics panel + navigation actions,
- export actions (3D SVG, 3D PNG, summary JSON).
- keyboard-accessible trajectory segment selection (`Enter`/`Space`) and clear-highlight control.

View File

@@ -5,7 +5,7 @@ import { App } from "./App";
const DEFAULT_CASE = {
model: {
wellName: "PLACEHOLDER-WELL",
company: "Veren",
company: "Majic",
measuredDepth: [0, 100, 200],
inclination: [0, 10, 20],
azimuth: [0, 90, 180],
@@ -14,7 +14,7 @@ const DEFAULT_CASE = {
},
rawFields: {
WellName: "PLACEHOLDER-WELL",
Company: "Veren",
Company: "Majic",
PumpDepth: "1727",
PumpingSpeed: "5",
UnitsSelection: "2",

View File

@@ -14,9 +14,10 @@ const taperBase = {
};
describe("engineering checks fixed thresholds", () => {
it("blocks run when pump depth and rod length mismatch exceeds 15 m", () => {
it("blocks run when pump depth and rod length mismatch exceeds 15 m (SI)", () => {
const state = {
...EMPTY_CASE_STATE,
unitsSelection: 1,
pumpDepth: 1000,
taper: [{ ...taperBase, length: 980, rodType: 3 }],
survey: [
@@ -31,6 +32,23 @@ describe("engineering checks fixed thresholds", () => {
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(true);
});
it("does not block run for small mismatch in imperial native units", () => {
const state = {
...EMPTY_CASE_STATE,
unitsSelection: 2,
pumpDepth: 1000,
taper: [{ ...taperBase, length: 980, rodType: 3 }],
survey: [
{ md: 0, inc: 0, azi: 0 },
{ md: 1000, inc: 0, azi: 0 }
]
};
const checks = runEngineeringChecks(state);
expect(checks.hasBlockingError).toBe(false);
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(false);
});
it("warns when survey ends shallower than pump depth", () => {
const state = {
...EMPTY_CASE_STATE,

View File

@@ -1,6 +1,7 @@
import type { CaseState } from "./caseModel";
export const PUMP_ROD_MISMATCH_M = 15;
const FT_TO_M = 0.3048;
export type EngineeringIssue = {
severity: "warning" | "error";
@@ -20,14 +21,19 @@ export function runEngineeringChecks(state: CaseState): EngineeringChecks {
const rodTotal = activeTaper.reduce((acc, t) => acc + t.length, 0);
const pumpDepth = state.pumpDepth;
if (rodTotal > 0 && pumpDepth > 0) {
const diff = Math.abs(pumpDepth - rodTotal);
if (diff > PUMP_ROD_MISMATCH_M) {
const depthScale = toMetersScale(state.unitsSelection);
const pumpDepthM = pumpDepth * depthScale;
const rodTotalM = rodTotal * depthScale;
const diffM = Math.abs(pumpDepthM - rodTotalM);
if (diffM > PUMP_ROD_MISMATCH_M) {
issues.push({
severity: "error",
code: "PUMP_ROD_MISMATCH_15M",
message: `Pump depth (${pumpDepth.toFixed(1)}) and total rod length (${rodTotal.toFixed(
message: `Pump depth (${pumpDepth.toFixed(1)} native, ${pumpDepthM.toFixed(
1
)}) differ by ${diff.toFixed(1)} m (> ${PUMP_ROD_MISMATCH_M} m limit).`
)} m) and total rod length (${rodTotal.toFixed(1)} native, ${rodTotalM.toFixed(
1
)} m) differ by ${diffM.toFixed(1)} m (> ${PUMP_ROD_MISMATCH_M} m limit).`
});
}
}
@@ -68,3 +74,7 @@ export function runEngineeringChecks(state: CaseState): EngineeringChecks {
};
}
function toMetersScale(unitsSelection: number): number {
return unitsSelection === 1 ? 1 : FT_TO_M;
}

View File

@@ -225,7 +225,9 @@ export function App() {
<footer className="app-statusbar">
<span>{statusMessage}</span>
<span>Company: {store.state.company || "—"}</span>
<span>
Well: {store.state.wellName || "—"} · Company: {store.state.company || "—"}
</span>
<span>Taper sections: {store.state.taper.filter((t) => t.length > 0).length}</span>
<span>Survey stations: {store.state.survey.length}</span>
</footer>

View File

@@ -29,9 +29,9 @@ export function KinematicsTab({
<>
<Fieldset legend="Pumping Unit / Surface Motion">
<p className="panel-note">
Drives the polished-rod boundary condition via pumping speed (SPM).
Diagnostic workflow with measured surface-card upload is planned for
a future pass (see <code>solver-api POST /solve/validate-card</code>).
Drives the polished-rod boundary condition via pumping speed (SPM). For measured-card
diagnostics, paste card data below, validate it, then switch Solver workflow to
<code> diagnostic</code> before running.
</p>
<Row label="Pumping Speed (SPM)" htmlFor="pumpingSpeed">
<NumberField

View File

@@ -53,12 +53,14 @@ export function deriveTrajectoryFrictionMultiplier(model) {
function rodTypeToProps(typeCode) {
const t = Math.round(typeCode);
if (t === 2) {
if (t === 3) {
return { E: FIBERGLASS_E, rho: FIBERGLASS_RHO };
}
return { E: STEEL_E, rho: STEEL_RHO };
}
export { rodTypeToProps };
function buildRodNodes(model) {
const nx = 48;
const nodes = nx + 1;

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { rodTypeToProps } from "../src/solverClient.js";
describe("solverClient rod type mapping", () => {
it("maps fiberglass rod type 3 to fiberglass properties", () => {
expect(rodTypeToProps(3)).toEqual({
E: 5.5e9,
rho: 1900
});
});
it("maps sinker and steel rod types to steel-like properties", () => {
expect(rodTypeToProps(2)).toEqual({
E: 2.05e11,
rho: 7850
});
expect(rodTypeToProps(0)).toEqual({
E: 2.05e11,
rho: 7850
});
expect(rodTypeToProps(1)).toEqual({
E: 2.05e11,
rho: 7850
});
});
});