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 | | **Pumping-unit kinematics** (Svinos tables, crank motion from `PumpingUnitID`) | Harmonic default; unit geometry unused |
| **Inverse calibration** | Fit damping / friction to measured downhole card | | **Inverse calibration** | Fit damping / friction to measured downhole card |
| **Fourier** analytical diagnostic | Optional `comparison.fourier` | | **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** 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 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 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 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 | | **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: - Fixed engineering gate in Solver tab:
- run blocked when `|PumpDepth - sum(TaperLengthArray)| > 15 m`. - run blocked when `|PumpDepth - sum(TaperLengthArray)| > 15 m`.
- survey MD monotonicity and minimum station-count checks. - 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: - Results tab now shows:
- uPlot dynacard overlays, - uPlot dynacard overlays,
- 3D projected wellbore with rod gradient and pump marker, - 3D projected wellbore with rod gradient and pump marker,
- interactive 3D view controls (rotate, pan, zoom, perspective/orthographic toggle, reset), - depth-hue and side-load risk overlays,
- highlighted bad-DLS segments, - segment highlight + clear-highlight controls,
- trajectory analytics table with row↔3D segment cross-highlight, - 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, - pump-placement diagnostics panel + navigation actions,
- export actions (3D SVG, 3D PNG, summary JSON). - export actions (3D SVG, 3D PNG, summary JSON).
- keyboard-accessible trajectory segment selection (`Enter`/`Space`) and clear-highlight control. - keyboard-accessible trajectory segment selection (`Enter`/`Space`) and clear-highlight control.

View File

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

View File

@@ -14,9 +14,10 @@ const taperBase = {
}; };
describe("engineering checks fixed thresholds", () => { 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 = { const state = {
...EMPTY_CASE_STATE, ...EMPTY_CASE_STATE,
unitsSelection: 1,
pumpDepth: 1000, pumpDepth: 1000,
taper: [{ ...taperBase, length: 980, rodType: 3 }], taper: [{ ...taperBase, length: 980, rodType: 3 }],
survey: [ survey: [
@@ -31,6 +32,23 @@ describe("engineering checks fixed thresholds", () => {
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(true); 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", () => { it("warns when survey ends shallower than pump depth", () => {
const state = { const state = {
...EMPTY_CASE_STATE, ...EMPTY_CASE_STATE,

View File

@@ -1,6 +1,7 @@
import type { CaseState } from "./caseModel"; import type { CaseState } from "./caseModel";
export const PUMP_ROD_MISMATCH_M = 15; export const PUMP_ROD_MISMATCH_M = 15;
const FT_TO_M = 0.3048;
export type EngineeringIssue = { export type EngineeringIssue = {
severity: "warning" | "error"; severity: "warning" | "error";
@@ -20,14 +21,19 @@ export function runEngineeringChecks(state: CaseState): EngineeringChecks {
const rodTotal = activeTaper.reduce((acc, t) => acc + t.length, 0); const rodTotal = activeTaper.reduce((acc, t) => acc + t.length, 0);
const pumpDepth = state.pumpDepth; const pumpDepth = state.pumpDepth;
if (rodTotal > 0 && pumpDepth > 0) { if (rodTotal > 0 && pumpDepth > 0) {
const diff = Math.abs(pumpDepth - rodTotal); const depthScale = toMetersScale(state.unitsSelection);
if (diff > PUMP_ROD_MISMATCH_M) { const pumpDepthM = pumpDepth * depthScale;
const rodTotalM = rodTotal * depthScale;
const diffM = Math.abs(pumpDepthM - rodTotalM);
if (diffM > PUMP_ROD_MISMATCH_M) {
issues.push({ issues.push({
severity: "error", severity: "error",
code: "PUMP_ROD_MISMATCH_15M", 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 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"> <footer className="app-statusbar">
<span>{statusMessage}</span> <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>Taper sections: {store.state.taper.filter((t) => t.length > 0).length}</span>
<span>Survey stations: {store.state.survey.length}</span> <span>Survey stations: {store.state.survey.length}</span>
</footer> </footer>

View File

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

View File

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