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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ coverage/
|
||||
solver-c/solver_main
|
||||
solver-c/solver_fea_main
|
||||
solver-c/test_solver
|
||||
solver-c/test_solver_sanitize
|
||||
*.o
|
||||
*.out
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ User goals: SROD-like transparency, measured card → downhole + pump movement,
|
||||
| Fourier analytical baseline | §3 | **Deferred** |
|
||||
| Full tube–tube contact (Eisner) | §6 | **Deferred** |
|
||||
|
||||
### Tubing gradient (GUI helper)
|
||||
|
||||
The **Fluid** tab can fill `TubingGradient` from a **heuristic** bulk-liquid hydrostatic estimate (water cut, oil API, water SG), aligned with the simplified mixture density used in `solver-api/src/xmlParser.js#computeFluidDensityKgM3`. `TubingGradient` is still parsed to `tubingGradientPaM` in Node but is **not** forwarded on the C JSON stdin payload today (`docs/engineering/field-traceability.md`). Wiring it into the damped-wave / pressure BC model would require `MATH_SPEC.md` + C changes + golden refresh.
|
||||
|
||||
---
|
||||
|
||||
## 4. API (quick reference)
|
||||
@@ -189,14 +193,14 @@ This section is the execution plan for the next pass, optimized for "feature-ric
|
||||
### 8.4 Priority 4 — Contract hardening
|
||||
|
||||
- Keep `schemaVersion: 2` additive contract stable by default.
|
||||
- Enforce option-gated heavy payloads (`profiles`, `diagnostics`, `fourier`).
|
||||
- Add traceability metadata endpoint/payload support for GUI and audits.
|
||||
- Enforce option-gated heavy payloads (`profiles`, `diagnostics`, `fourier`) — **implemented in C stdout** (`enableProfiles`, `enableDiagnosticsDetail`, `enableFourierBaseline`); default API responses omit heavy blocks.
|
||||
- Traceability metadata — **`fieldTraceability` on `GET /case/default`, `POST /case/parse`, `POST /solve`, `GET /solve/default`** via [`solver-api/src/fieldTraceability.js`](../solver-api/src/fieldTraceability.js).
|
||||
|
||||
**Acceptance gate:** backward-compat tests pass on default endpoints.
|
||||
|
||||
### 8.5 Priority 5 — CI/release readiness
|
||||
|
||||
- Add sanitizer runs (ASan/UBSan) for C paths.
|
||||
- Add sanitizer runs (ASan/UBSan) for C paths — **`make test-solver-sanitize`** in root [`Makefile`](../Makefile).
|
||||
- Add runtime/performance budgets on representative cases.
|
||||
- Enforce quality artifact generation in CI (comparison summaries + drift reports).
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: run down logs test smoke
|
||||
.PHONY: run down logs test smoke test-solver-sanitize
|
||||
|
||||
run:
|
||||
docker compose up --build
|
||||
@@ -16,6 +16,14 @@ test:
|
||||
cd gui-ts && npm test
|
||||
./solver-c/test_solver
|
||||
|
||||
# ASan/UBSan regression for solver-c (rebuilds a throwaway binary under solver-c/)
|
||||
test-solver-sanitize:
|
||||
cd solver-c && gcc -std=c99 -fsanitize=address,undefined -g -O1 \
|
||||
-Iinclude \
|
||||
src/solver_common.c src/json_stdin.c src/trajectory.c src/solver_diagnostic.c \
|
||||
src/solver.c src/solver_fea.c src/solver_fourier.c \
|
||||
tests/test_solver.c -lm -o test_solver_sanitize && ./test_solver_sanitize
|
||||
|
||||
smoke:
|
||||
@echo "Checking API health..."
|
||||
@for i in {1..30}; do \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Rods Cursor — Rod-string solver (math-first)
|
||||
# Majic Rod Solver — Rod-string solver stack (math-first)
|
||||
|
||||
Deterministic **C** numerical core (FDM + FEA), **Node** API for XML and orchestration, **TypeScript** GUI for workflow. See **[AGENTS.md](AGENTS.md)** for agent rules and **[Agents/MATH_SPEC.md](Agents/MATH_SPEC.md)** for equations and paper citations.
|
||||
|
||||
@@ -108,6 +108,7 @@ make down
|
||||
|
||||
```bash
|
||||
make test # solver-api vitest + gui-ts tests + solver-c test_solver
|
||||
make test-solver-sanitize # optional: ASan/UBSan build of solver-c test harness
|
||||
./solver-c/test_solver
|
||||
```
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<CasingHeadPressure>0</CasingHeadPressure>
|
||||
<CBalOption>0</CBalOption>
|
||||
<Comments />
|
||||
<Company>Veren</Company>
|
||||
<Company>Majic</Company>
|
||||
<CounterWeight>0</CounterWeight>
|
||||
<CounterWeightInertia>0</CounterWeightInertia>
|
||||
<CrankHole>1 - 367.3 (cm)</CrankHole>
|
||||
@@ -118,7 +118,7 @@
|
||||
<RotationKey>-1</RotationKey>
|
||||
<Runtime>24</Runtime>
|
||||
<SelectHydralicUnit>0</SelectHydralicUnit>
|
||||
<SeparatorPressure>275.79</SeparatorPressure>
|
||||
<SeparatorPressure>276</SeparatorPressure>
|
||||
<ServiceFactor>0.8</ServiceFactor>
|
||||
<ShallowWell>0</ShallowWell>
|
||||
<SheaveOption>0</SheaveOption>
|
||||
@@ -140,8 +140,8 @@
|
||||
<TaperWeightArray>2.224:1.634:6:1.634:1.634:0:0:0:0:0:0:0:0:0:0:0</TaperWeightArray>
|
||||
<TotalDepartureOfTarget>0</TotalDepartureOfTarget>
|
||||
<TrueFluidDepth>0</TrueFluidDepth>
|
||||
<TubingAnchorLocation>1361.3</TubingAnchorLocation>
|
||||
<TubingGradient>9.989</TubingGradient>
|
||||
<TubingAnchorLocation>1361</TubingAnchorLocation>
|
||||
<TubingGradient>10</TubingGradient>
|
||||
<TubingSize>3</TubingSize>
|
||||
<TVD>0</TVD>
|
||||
<UnitsSelection>2</UnitsSelection>
|
||||
@@ -152,7 +152,7 @@
|
||||
<WaterCut>73</WaterCut>
|
||||
<WaterSpecGravity>1.096</WaterSpecGravity>
|
||||
<WellDeviationType>1</WellDeviationType>
|
||||
<WellName>191/01-27-007-09W2/00</WellName>
|
||||
<WellName>PLACEHOLDER-WELL</WellName>
|
||||
<WheeledGuideFrictionRatio>0.1</WheeledGuideFrictionRatio>
|
||||
<DesiredMaxSPM>1</DesiredMaxSPM>
|
||||
<DesiredMinSPM>1</DesiredMinSPM>
|
||||
|
||||
@@ -1 +1 @@
|
||||
d433dd1061c9f26679507fac42299d97d6d9c0b446651eeaa6ac03529e424fa0
|
||||
5b6a699556725bee5efad5d4d32bd2b8c168a1f0104293014381c00cb2ab508d
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rod Solver GUI</title>
|
||||
<title>Majic Rod Solver</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { App } from "./App";
|
||||
|
||||
const DEFAULT_CASE = {
|
||||
model: {
|
||||
wellName: "191/01-27-007-09W2/00",
|
||||
wellName: "PLACEHOLDER-WELL",
|
||||
company: "Veren",
|
||||
measuredDepth: [0, 100, 200],
|
||||
inclination: [0, 10, 20],
|
||||
@@ -13,7 +13,7 @@ const DEFAULT_CASE = {
|
||||
pumpDepth: 1727
|
||||
},
|
||||
rawFields: {
|
||||
WellName: "191/01-27-007-09W2/00",
|
||||
WellName: "PLACEHOLDER-WELL",
|
||||
Company: "Veren",
|
||||
PumpDepth: "1727",
|
||||
PumpingSpeed: "5",
|
||||
@@ -61,7 +61,7 @@ describe("App tabbed shell", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const wellInput = screen.getByLabelText(/Well Name/i) as HTMLInputElement;
|
||||
expect(wellInput.value).toBe("191/01-27-007-09W2/00");
|
||||
expect(wellInput.value).toBe("PLACEHOLDER-WELL");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,7 +139,9 @@ describe("App tabbed shell", () => {
|
||||
const body = JSON.parse(String(solveCall?.init?.body ?? "{}"));
|
||||
expect(body.solverModel).toBeDefined();
|
||||
expect(typeof body.xml).toBe("string");
|
||||
expect(body.xml).toContain("<WellName>191/01-27-007-09W2/00</WellName>");
|
||||
expect(body.xml).toContain("<WellName>PLACEHOLDER-WELL</WellName>");
|
||||
expect(body.options?.enableProfiles).toBe(true);
|
||||
expect(body.options?.enableFourierBaseline).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks solver run when engineering checks report blocking errors", async () => {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_CASE_STATE } from "../caseModel";
|
||||
import {
|
||||
DLS_BAD_SECTION_THRESHOLD,
|
||||
PUMP_ROD_MISMATCH_M,
|
||||
runEngineeringChecks
|
||||
} from "../engineeringChecks";
|
||||
import { PUMP_ROD_MISMATCH_M, runEngineeringChecks } from "../engineeringChecks";
|
||||
|
||||
const taperBase = {
|
||||
diameter: 19.05,
|
||||
modulus: 30.5,
|
||||
weightLbfPerFt: 1.634,
|
||||
mtsLbf: 792_897,
|
||||
guidesEnabled: false,
|
||||
guideCount: 0,
|
||||
guideTypeToken: "",
|
||||
rodGuideWeightLbfPerFt: 0
|
||||
};
|
||||
|
||||
describe("engineering checks fixed thresholds", () => {
|
||||
it("blocks run when pump depth and rod length mismatch exceeds 15 m", () => {
|
||||
const state = {
|
||||
...EMPTY_CASE_STATE,
|
||||
pumpDepth: 1000,
|
||||
taper: [
|
||||
{ diameter: 19.05, length: 980, modulus: 30.5, rodType: 3 }
|
||||
],
|
||||
taper: [{ ...taperBase, length: 980, rodType: 3 }],
|
||||
survey: [
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: 1000, inc: 0, azi: 0 }
|
||||
@@ -26,13 +31,11 @@ describe("engineering checks fixed thresholds", () => {
|
||||
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags DLS warning above 15 deg/100 threshold", () => {
|
||||
it("warns when survey ends shallower than pump depth", () => {
|
||||
const state = {
|
||||
...EMPTY_CASE_STATE,
|
||||
pumpDepth: 1000,
|
||||
taper: [
|
||||
{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }
|
||||
],
|
||||
taper: [{ ...taperBase, length: 1000, rodType: 3 }],
|
||||
survey: [
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: 100, inc: 20, azi: 0 },
|
||||
@@ -41,8 +44,6 @@ describe("engineering checks fixed thresholds", () => {
|
||||
};
|
||||
|
||||
const checks = runEngineeringChecks(state);
|
||||
expect(DLS_BAD_SECTION_THRESHOLD).toBe(15);
|
||||
expect(checks.issues.some((i) => i.code === "DLS_HIGH")).toBe(true);
|
||||
expect(checks.issues.some((i) => i.code === "SURVEY_BELOW_PUMP_MISSING")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,23 @@ describe("xmlExport round-trip", () => {
|
||||
const origTaperD = raw(parsed, "TaperDiameterArray").split(":").map(Number);
|
||||
const reTaperD = raw(reparsed, "TaperDiameterArray").split(":").map(Number);
|
||||
expect(reTaperD).toEqual(origTaperD);
|
||||
|
||||
const origW = raw(parsed, "TaperWeightArray").split(":").map(Number);
|
||||
const reW = raw(reparsed, "TaperWeightArray").split(":").map(Number);
|
||||
expect(reW).toEqual(origW);
|
||||
|
||||
const origMts = raw(parsed, "TaperMTSArray").split(":").map(Number);
|
||||
const reMts = raw(reparsed, "TaperMTSArray").split(":").map(Number);
|
||||
expect(reMts).toEqual(origMts);
|
||||
|
||||
expect(raw(reparsed, "RodGuideTypeArray")).toBe(raw(parsed, "RodGuideTypeArray"));
|
||||
|
||||
const origGw = raw(parsed, "RodGuideWeightArray").split(":").map(Number);
|
||||
const reGw = raw(reparsed, "RodGuideWeightArray").split(":").map(Number);
|
||||
expect(reGw).toEqual(origGw);
|
||||
|
||||
expect(Number(raw(reparsed, "SinkerBarDiameter"))).toBe(Number(raw(parsed, "SinkerBarDiameter")));
|
||||
expect(Number(raw(reparsed, "SinkerBarLength"))).toBe(Number(raw(parsed, "SinkerBarLength")));
|
||||
});
|
||||
|
||||
it("preserves unsupported / untouched fields verbatim", () => {
|
||||
|
||||
@@ -13,14 +13,25 @@
|
||||
export type RawFieldValue = string | Record<string, unknown> | undefined;
|
||||
|
||||
export type TaperRow = {
|
||||
/** Taper diameter in the XML's native unit (mm in base case). */
|
||||
/** Taper diameter in the XML's native unit (mm when >2, inches when ≤2 per xmlParser). */
|
||||
diameter: number;
|
||||
/** Taper length in the XML's native unit (typically metres / feet mix). */
|
||||
/** Taper length in the XML's native unit (feet for imperial oilfield; metres when SI). */
|
||||
length: number;
|
||||
/** Young's modulus in Mpsi (base case stores 30.5). */
|
||||
modulus: number;
|
||||
/** Rod type code (0=steel, 3=fiberglass, 2=sinker, etc.). */
|
||||
rodType: number;
|
||||
/** Weight in lb/ft for imperial case files (TaperWeightArray). */
|
||||
weightLbfPerFt: number;
|
||||
/** Minimum tensile strength in lbf (TaperMTSArray). */
|
||||
mtsLbf: number;
|
||||
guidesEnabled: boolean;
|
||||
/** Molded-guide count for this taper when guidesEnabled (TaperGuidesCountArray; -1 when disabled). */
|
||||
guideCount: number;
|
||||
/** One letter per taper station from RodGuideTypeArray (e.g. M, N). */
|
||||
guideTypeToken: string;
|
||||
/** RodGuideWeightArray entry (lb/ft style magnitude as in case file). */
|
||||
rodGuideWeightLbfPerFt: number;
|
||||
};
|
||||
|
||||
export type SurveyRow = {
|
||||
@@ -75,6 +86,25 @@ export type CaseState = {
|
||||
downStrokeDamping: number;
|
||||
nonDimensionalFluidDamping: number;
|
||||
|
||||
sinkerBarDiameter: number;
|
||||
sinkerBarLength: number;
|
||||
|
||||
/**
|
||||
* Colon-slot count for RodGuideTypeArray / RodGuideWeightArray from the loaded XML
|
||||
* (may be shorter than padded taper row count).
|
||||
*/
|
||||
rodGuideSlotCount: number;
|
||||
|
||||
/**
|
||||
* Trailing colon slots when TaperWeightArray / TaperMTSArray / TaperGuidesCountArray are
|
||||
* longer than the core taper row count (diameter / length / modulus / rod type).
|
||||
*/
|
||||
taperParallelSuffix: {
|
||||
weights: number[];
|
||||
mts: number[];
|
||||
guidesCounts: number[];
|
||||
};
|
||||
|
||||
// --- Units ---
|
||||
unitsSelection: number;
|
||||
|
||||
@@ -92,14 +122,28 @@ export type CaseState = {
|
||||
* Runtime settings that are not part of the XML case file but that the GUI
|
||||
* needs to send to the solver API.
|
||||
*/
|
||||
export type SolverOutputOptions = {
|
||||
enableProfiles: boolean;
|
||||
enableDiagnosticsDetail: boolean;
|
||||
enableFourierBaseline: boolean;
|
||||
fourierHarmonics: number;
|
||||
};
|
||||
|
||||
export type RunSettings = {
|
||||
solverModel: "fdm" | "fea" | "both";
|
||||
workflow: "predictive" | "diagnostic";
|
||||
outputOptions: SolverOutputOptions;
|
||||
};
|
||||
|
||||
export const INITIAL_RUN_SETTINGS: RunSettings = {
|
||||
solverModel: "both",
|
||||
workflow: "predictive"
|
||||
workflow: "predictive",
|
||||
outputOptions: {
|
||||
enableProfiles: true,
|
||||
enableDiagnosticsDetail: false,
|
||||
enableFourierBaseline: false,
|
||||
fourierHarmonics: 8
|
||||
}
|
||||
};
|
||||
|
||||
export const EMPTY_CASE_STATE: CaseState = {
|
||||
@@ -132,6 +176,10 @@ export const EMPTY_CASE_STATE: CaseState = {
|
||||
upStrokeDamping: 0,
|
||||
downStrokeDamping: 0,
|
||||
nonDimensionalFluidDamping: 0,
|
||||
sinkerBarDiameter: 0,
|
||||
sinkerBarLength: 0,
|
||||
rodGuideSlotCount: 0,
|
||||
taperParallelSuffix: { weights: [], mts: [], guidesCounts: [] },
|
||||
unitsSelection: 0,
|
||||
rawFields: {},
|
||||
rawFieldOrder: []
|
||||
@@ -154,6 +202,13 @@ export const FIRST_CLASS_XML_KEYS = [
|
||||
"TaperLengthArray",
|
||||
"TaperModulusArray",
|
||||
"RodTypeArray",
|
||||
"TaperWeightArray",
|
||||
"TaperMTSArray",
|
||||
"TaperGuidesCountArray",
|
||||
"RodGuideTypeArray",
|
||||
"RodGuideWeightArray",
|
||||
"SinkerBarDiameter",
|
||||
"SinkerBarLength",
|
||||
"PumpDiameter",
|
||||
"PumpFriction",
|
||||
"PumpIntakePressure",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { CaseState } from "./caseModel";
|
||||
import { computeDoglegSeverityDegPer100 } from "./trajectoryMetrics";
|
||||
|
||||
export const PUMP_ROD_MISMATCH_M = 15;
|
||||
export const DLS_BAD_SECTION_THRESHOLD = 15;
|
||||
|
||||
export type EngineeringIssue = {
|
||||
severity: "warning" | "error";
|
||||
@@ -42,10 +40,8 @@ export function runEngineeringChecks(state: CaseState): EngineeringChecks {
|
||||
});
|
||||
} else {
|
||||
let nonMonotonic = false;
|
||||
let maxDls = 0;
|
||||
for (let i = 1; i < state.survey.length; i += 1) {
|
||||
if (state.survey[i].md <= state.survey[i - 1].md) nonMonotonic = true;
|
||||
maxDls = Math.max(maxDls, computeDoglegSeverityDegPer100(state.survey[i - 1], state.survey[i]));
|
||||
}
|
||||
if (nonMonotonic) {
|
||||
issues.push({
|
||||
@@ -54,15 +50,6 @@ export function runEngineeringChecks(state: CaseState): EngineeringChecks {
|
||||
message: "Measured depth must strictly increase between survey stations."
|
||||
});
|
||||
}
|
||||
if (maxDls > DLS_BAD_SECTION_THRESHOLD) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "DLS_HIGH",
|
||||
message: `High dogleg severity detected (max ${maxDls.toFixed(
|
||||
2
|
||||
)} deg/100 > ${DLS_BAD_SECTION_THRESHOLD} deg/100 bad-section threshold).`
|
||||
});
|
||||
}
|
||||
const maxMd = state.survey[state.survey.length - 1].md;
|
||||
if (pumpDepth > 0 && maxMd > 0 && maxMd < pumpDepth - 10) {
|
||||
issues.push({
|
||||
|
||||
71
gui-ts/src/state/rodCatalog.ts
Normal file
71
gui-ts/src/state/rodCatalog.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Heuristic rod property lookups for the GUI (lb/ft, Mpsi, MTS lbf).
|
||||
* Seeded from data/cases/base-case.xml taper rows; extended with typical steel API weights.
|
||||
* Not a substitute for vendor tables — tag at call sites per AGENTS.md.
|
||||
*/
|
||||
|
||||
export type RodCatalogEntry = {
|
||||
weightLbfPerFt: number;
|
||||
mtsLbf: number;
|
||||
modulusMpsi: number;
|
||||
};
|
||||
|
||||
/** Nominal OD in mm (XML convention: value > 2 ⇒ millimetres). */
|
||||
export const ROD_DIAMETER_MM_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 19.05, label: '3/4" (19.05 mm)' },
|
||||
{ value: 22.225, label: '7/8" (22.225 mm)' },
|
||||
{ value: 25.4, label: '1" (25.4 mm)' },
|
||||
{ value: 28.575, label: '1 1/8" (28.575 mm)' },
|
||||
{ value: 31.75, label: '1 1/4" (31.75 mm)' },
|
||||
{ value: 34.925, label: '1 3/8" (34.925 mm)' },
|
||||
{ value: 38.1, label: '1 1/2" (38.1 mm)' },
|
||||
{ value: 41.275, label: '1 5/8" (41.275 mm)' },
|
||||
{ value: 44.45, label: '1 3/4" (44.45 mm)' },
|
||||
{ value: 47.625, label: '1 7/8" (47.625 mm)' },
|
||||
{ value: 50.8, label: '2" (50.8 mm)' }
|
||||
];
|
||||
|
||||
const catalog = new Map<string, RodCatalogEntry>();
|
||||
|
||||
function key(rodType: number, diameterMm: number): string {
|
||||
return `${rodType}:${diameterMm}`;
|
||||
}
|
||||
|
||||
function seed(k: string, e: RodCatalogEntry) {
|
||||
catalog.set(k, e);
|
||||
}
|
||||
|
||||
// Fiberglass (type 3) — from base-case taper
|
||||
seed("3:22.225", { weightLbfPerFt: 2.224, mtsLbf: 792_897.055, modulusMpsi: 30.5 });
|
||||
seed("3:19.05", { weightLbfPerFt: 1.634, mtsLbf: 792_897.055, modulusMpsi: 30.5 });
|
||||
// Sinker (type 2) — base-case sinker section
|
||||
seed("2:38.1", { weightLbfPerFt: 6, mtsLbf: 620_528.13, modulusMpsi: 30.5 });
|
||||
|
||||
// Steel (types 0, 1) — typical API approximations (heuristic)
|
||||
seed("0:19.05", { weightLbfPerFt: 2.22, mtsLbf: 850_000, modulusMpsi: 30 });
|
||||
seed("0:22.225", { weightLbfPerFt: 2.85, mtsLbf: 900_000, modulusMpsi: 30 });
|
||||
seed("0:25.4", { weightLbfPerFt: 3.65, mtsLbf: 950_000, modulusMpsi: 30 });
|
||||
seed("0:28.575", { weightLbfPerFt: 4.5, mtsLbf: 1_000_000, modulusMpsi: 30 });
|
||||
seed("0:31.75", { weightLbfPerFt: 5.45, mtsLbf: 1_050_000, modulusMpsi: 30 });
|
||||
seed("1:19.05", { weightLbfPerFt: 2.22, mtsLbf: 880_000, modulusMpsi: 30 });
|
||||
seed("1:22.225", { weightLbfPerFt: 2.85, mtsLbf: 930_000, modulusMpsi: 30 });
|
||||
|
||||
/** Nearest catalog entry for rod type + diameter (mm), or best-effort default. */
|
||||
export function lookupRodCatalog(rodType: number, diameterMm: number): RodCatalogEntry {
|
||||
const direct = catalog.get(key(rodType, diameterMm));
|
||||
if (direct) return { ...direct };
|
||||
|
||||
const t = Math.round(rodType);
|
||||
if (t === 3) {
|
||||
const fg = catalog.get(key(3, diameterMm));
|
||||
if (fg) return { ...fg };
|
||||
return { weightLbfPerFt: 1.8, mtsLbf: 750_000, modulusMpsi: 30.5 };
|
||||
}
|
||||
if (t === 2) {
|
||||
const sk = catalog.get(key(2, diameterMm));
|
||||
if (sk) return { ...sk };
|
||||
return { weightLbfPerFt: 6, mtsLbf: 620_000, modulusMpsi: 30.5 };
|
||||
}
|
||||
const st = catalog.get(key(0, diameterMm)) ?? catalog.get(key(0, 22.225))!;
|
||||
return { ...st };
|
||||
}
|
||||
43
gui-ts/src/state/rodJointLength.ts
Normal file
43
gui-ts/src/state/rodJointLength.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { RawFieldValue } from "./caseModel";
|
||||
|
||||
function textOf(value: RawFieldValue): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj._ === "string") return obj._;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function numberOf(value: RawFieldValue, fallback: number): number {
|
||||
const text = textOf(value).trim();
|
||||
if (!text) return fallback;
|
||||
const n = Number(text);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
const FT_TO_M = 0.3048;
|
||||
|
||||
/** Joint length in metres from RodLengthFor* XML fields. */
|
||||
export function rodJointLengthM(
|
||||
rawFields: Record<string, RawFieldValue>,
|
||||
rodType: number
|
||||
): number {
|
||||
const steelM = numberOf(rawFields.RodLengthForSteel, 7.62);
|
||||
const fgM = numberOf(rawFields.RodLengthForFiberglass, 11.43);
|
||||
if (Math.round(rodType) === 3) return fgM;
|
||||
if (Math.round(rodType) === 2) return steelM;
|
||||
return steelM;
|
||||
}
|
||||
|
||||
/** Joint length in case-native depth units (feet imperial, metres SI). */
|
||||
export function rodJointLengthNative(
|
||||
rawFields: Record<string, RawFieldValue>,
|
||||
rodType: number,
|
||||
unitsSelection: number
|
||||
): number {
|
||||
const Lm = rodJointLengthM(rawFields, rodType);
|
||||
if (unitsSelection === 1) return Lm;
|
||||
return Lm / FT_TO_M;
|
||||
}
|
||||
40
gui-ts/src/state/tubingGradientEstimate.ts
Normal file
40
gui-ts/src/state/tubingGradientEstimate.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Heuristic tubing liquid gradient from bulk fluid properties (GUI helper only).
|
||||
* Mixed density uses the same simplified oil density form as solver-api/xmlParser.js.
|
||||
* TubingGradient in imperial case files is treated as psi/ft by xmlParser.
|
||||
*/
|
||||
|
||||
import type { CaseState } from "./caseModel";
|
||||
|
||||
const FT_TO_M = 0.3048;
|
||||
const PSI_TO_PA = 6894.757293168;
|
||||
|
||||
function mixedFluidDensityKgM3(state: Pick<CaseState, "waterCut" | "waterSpecGravity" | "fluidLevelOilGravity">): number {
|
||||
const wc = Math.max(0, Math.min(100, state.waterCut)) / 100;
|
||||
const rhoW = 1000 * (state.waterSpecGravity || 1.0);
|
||||
const api = state.fluidLevelOilGravity || 35;
|
||||
const rhoOil = (141.5 / (api + 131.5)) * 999.012;
|
||||
const rho = wc * rhoW + (1 - wc) * rhoOil;
|
||||
if (!Number.isFinite(rho) || rho <= 0) return 1000;
|
||||
return rho;
|
||||
}
|
||||
|
||||
/** Estimate gradient in Pa/m (SI). */
|
||||
export function estimateTubingGradientPaM(
|
||||
state: Pick<CaseState, "waterCut" | "waterSpecGravity" | "fluidLevelOilGravity">
|
||||
): number {
|
||||
return mixedFluidDensityKgM3(state) * 9.80665;
|
||||
}
|
||||
|
||||
/** psi/ft for imperial UI / XML native field. */
|
||||
export function estimateTubingGradientPsiPerFt(
|
||||
state: Pick<CaseState, "waterCut" | "waterSpecGravity" | "fluidLevelOilGravity">
|
||||
): number {
|
||||
const paM = estimateTubingGradientPaM(state);
|
||||
return (paM / PSI_TO_PA) * FT_TO_M;
|
||||
}
|
||||
|
||||
/** Pa/m for metric UI when UnitsSelection === 1. */
|
||||
export function estimateTubingGradientPaMForMetric(state: Parameters<typeof estimateTubingGradientPaM>[0]): number {
|
||||
return estimateTubingGradientPaM(state);
|
||||
}
|
||||
45
gui-ts/src/state/unitsDisplay.ts
Normal file
45
gui-ts/src/state/unitsDisplay.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const MM_PER_IN = 25.4;
|
||||
|
||||
export function inchesFromDiameterMm(diameterMm: number): number {
|
||||
return diameterMm / MM_PER_IN;
|
||||
}
|
||||
|
||||
export function mmFromInches(inches: number): number {
|
||||
return inches * MM_PER_IN;
|
||||
}
|
||||
|
||||
/** Pump plunger sizes (inches) for dropdown — stored as mm in case XML when value > 2. */
|
||||
export const PUMP_PLUNGER_INCH_OPTIONS: number[] = [];
|
||||
for (let eighths = 10; eighths <= 20; eighths += 1) {
|
||||
PUMP_PLUNGER_INCH_OPTIONS.push(eighths / 8);
|
||||
}
|
||||
|
||||
export function pumpDiameterMmFromInches(inches: number): number {
|
||||
return mmFromInches(inches);
|
||||
}
|
||||
|
||||
export function formatInchesLabel(inches: number): string {
|
||||
const tol = 1e-6;
|
||||
const whole = Math.floor(inches + tol);
|
||||
const frac = inches - whole;
|
||||
if (Math.abs(frac) < 1e-5) return `${whole}"`;
|
||||
const eighths = Math.round(frac * 8);
|
||||
if (eighths <= 0) return `${whole}"`;
|
||||
if (eighths === 8) return `${whole + 1}"`;
|
||||
return `${whole} ${eighths}/8"`;
|
||||
}
|
||||
|
||||
export function formatMm(mm: number, decimals = 2): string {
|
||||
return `${mm.toFixed(decimals)} mm`;
|
||||
}
|
||||
|
||||
/** Dual label for pump diameter stored as mm (XML convention for this case). */
|
||||
export function formatPumpDiameterDual(diameterMm: number, unitsSelection: number): string {
|
||||
const inches = inchesFromDiameterMm(diameterMm);
|
||||
const inLabel = formatInchesLabel(inches);
|
||||
const mmLabel = formatMm(diameterMm, 2);
|
||||
if (unitsSelection === 1) {
|
||||
return `${mmLabel} (${inLabel})`;
|
||||
}
|
||||
return `${inLabel} (${mmLabel})`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { CaseState, SurveyRow, TaperRow } from "./caseModel";
|
||||
import { EMPTY_CASE_STATE } from "./caseModel";
|
||||
import { lookupRodCatalog } from "./rodCatalog";
|
||||
|
||||
export type CaseStore = {
|
||||
state: CaseState;
|
||||
@@ -8,6 +9,7 @@ export type CaseStore = {
|
||||
update: <K extends keyof CaseState>(key: K, value: CaseState[K]) => void;
|
||||
setSurvey: (rows: SurveyRow[]) => void;
|
||||
addSurveyRow: (row?: Partial<SurveyRow>) => void;
|
||||
insertSurveyRowBelow: (index: number, row?: Partial<SurveyRow>) => void;
|
||||
removeSurveyRow: (index: number) => void;
|
||||
updateSurveyRow: (index: number, patch: Partial<SurveyRow>) => void;
|
||||
setTaper: (rows: TaperRow[]) => void;
|
||||
@@ -40,6 +42,24 @@ export function useCaseStore(initial: CaseState = EMPTY_CASE_STATE): CaseStore {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const insertSurveyRowBelow = useCallback((index: number, row: Partial<SurveyRow> = {}) => {
|
||||
setStateInternal((prev) => {
|
||||
const survey = [...prev.survey];
|
||||
const a = survey[index];
|
||||
const b = survey[index + 1];
|
||||
let md = row.md;
|
||||
if (md === undefined) {
|
||||
if (a && b) md = (a.md + b.md) / 2;
|
||||
else if (a) md = a.md + (a.md > 0 ? 50 : 100);
|
||||
else md = 0;
|
||||
}
|
||||
const inc = row.inc ?? a?.inc ?? 0;
|
||||
const azi = row.azi ?? a?.azi ?? 0;
|
||||
survey.splice(index + 1, 0, { md: md ?? 0, inc, azi });
|
||||
return { ...prev, survey };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeSurveyRow = useCallback((index: number) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
@@ -55,22 +75,40 @@ export function useCaseStore(initial: CaseState = EMPTY_CASE_STATE): CaseStore {
|
||||
}, []);
|
||||
|
||||
const setTaper = useCallback((rows: TaperRow[]) => {
|
||||
setStateInternal((prev) => ({ ...prev, taper: rows }));
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
taper: rows,
|
||||
rodGuideSlotCount: Math.max(prev.rodGuideSlotCount, rows.length),
|
||||
taperParallelSuffix: { weights: [], mts: [], guidesCounts: [] }
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addTaperRow = useCallback((row: Partial<TaperRow> = {}) => {
|
||||
setStateInternal((prev) => ({
|
||||
setStateInternal((prev) => {
|
||||
const d = row.diameter ?? 19.05;
|
||||
const rt = row.rodType ?? 0;
|
||||
const cat = lookupRodCatalog(rt, d > 2 ? d : d * 25.4);
|
||||
const nextLen = prev.taper.length + 1;
|
||||
return {
|
||||
...prev,
|
||||
rodGuideSlotCount: Math.max(prev.rodGuideSlotCount, nextLen),
|
||||
taper: [
|
||||
...prev.taper,
|
||||
{
|
||||
diameter: row.diameter ?? 0,
|
||||
diameter: row.diameter ?? 19.05,
|
||||
length: row.length ?? 0,
|
||||
modulus: row.modulus ?? 30.5,
|
||||
rodType: row.rodType ?? 0
|
||||
modulus: row.modulus ?? cat.modulusMpsi,
|
||||
rodType: rt,
|
||||
weightLbfPerFt: row.weightLbfPerFt ?? cat.weightLbfPerFt,
|
||||
mtsLbf: row.mtsLbf ?? cat.mtsLbf,
|
||||
guidesEnabled: row.guidesEnabled ?? false,
|
||||
guideCount: row.guideCount ?? 0,
|
||||
guideTypeToken: row.guideTypeToken ?? "",
|
||||
rodGuideWeightLbfPerFt: row.rodGuideWeightLbfPerFt ?? 0
|
||||
}
|
||||
]
|
||||
}));
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeTaperRow = useCallback((index: number) => {
|
||||
@@ -104,6 +142,7 @@ export function useCaseStore(initial: CaseState = EMPTY_CASE_STATE): CaseStore {
|
||||
update,
|
||||
setSurvey,
|
||||
addSurveyRow,
|
||||
insertSurveyRowBelow,
|
||||
removeSurveyRow,
|
||||
updateSurveyRow,
|
||||
setTaper,
|
||||
@@ -118,6 +157,7 @@ export function useCaseStore(initial: CaseState = EMPTY_CASE_STATE): CaseStore {
|
||||
update,
|
||||
setSurvey,
|
||||
addSurveyRow,
|
||||
insertSurveyRowBelow,
|
||||
removeSurveyRow,
|
||||
updateSurveyRow,
|
||||
setTaper,
|
||||
|
||||
@@ -53,6 +53,20 @@ export function serializeCaseXml(state: CaseState): string {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function serializeTaperWeights(state: CaseState): string {
|
||||
const head = state.taper.map((r) => formatNumber(r.weightLbfPerFt));
|
||||
const tail = state.taperParallelSuffix.weights.map((n) => formatNumber(n));
|
||||
if (!head.length && !tail.length) return "0";
|
||||
return [...head, ...tail].join(":");
|
||||
}
|
||||
|
||||
function serializeTaperMts(state: CaseState): string {
|
||||
const head = state.taper.map((r) => formatNumber(r.mtsLbf));
|
||||
const tail = state.taperParallelSuffix.mts.map((n) => formatNumber(n));
|
||||
if (!head.length && !tail.length) return "0";
|
||||
return [...head, ...tail].join(":");
|
||||
}
|
||||
|
||||
function buildFirstClassMap(state: CaseState): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
m.set("WellName", state.wellName);
|
||||
@@ -72,6 +86,34 @@ function buildFirstClassMap(state: CaseState): Map<string, string> {
|
||||
m.set("TaperLengthArray", serializeColonArray(state.taper.map((r) => r.length)));
|
||||
m.set("TaperModulusArray", serializeColonArray(state.taper.map((r) => r.modulus)));
|
||||
m.set("RodTypeArray", serializeColonArray(state.taper.map((r) => r.rodType)));
|
||||
m.set("TaperWeightArray", serializeTaperWeights(state));
|
||||
m.set("TaperMTSArray", serializeTaperMts(state));
|
||||
m.set(
|
||||
"TaperGuidesCountArray",
|
||||
(() => {
|
||||
if (!state.taper.length && !state.taperParallelSuffix.guidesCounts.length) return "-1";
|
||||
const head = state.taper
|
||||
.map((r) => (r.guidesEnabled ? r.guideCount : -1))
|
||||
.map(formatNumber);
|
||||
const tail = state.taperParallelSuffix.guidesCounts.map((n) => formatNumber(n));
|
||||
return [...head, ...tail].join(":");
|
||||
})()
|
||||
);
|
||||
{
|
||||
const guideSlots =
|
||||
state.rodGuideSlotCount > 0 ? state.rodGuideSlotCount : Math.max(state.taper.length, 1);
|
||||
const types: string[] = [];
|
||||
const gWeights: string[] = [];
|
||||
for (let i = 0; i < guideSlots; i += 1) {
|
||||
const row = state.taper[i];
|
||||
types.push(row?.guideTypeToken ?? "");
|
||||
gWeights.push(formatNumber(row?.rodGuideWeightLbfPerFt ?? 0));
|
||||
}
|
||||
m.set("RodGuideTypeArray", types.join(":"));
|
||||
m.set("RodGuideWeightArray", gWeights.join(":"));
|
||||
}
|
||||
m.set("SinkerBarDiameter", formatNumber(state.sinkerBarDiameter));
|
||||
m.set("SinkerBarLength", formatNumber(state.sinkerBarLength));
|
||||
|
||||
m.set("PumpDiameter", formatNumber(state.pumpDiameter));
|
||||
m.set("PumpFriction", formatNumber(state.pumpFriction));
|
||||
|
||||
@@ -70,17 +70,35 @@ export function hydrateFromParsed(parsed: ParsedCase): CaseState {
|
||||
const length = parseColonArray(raw.TaperLengthArray);
|
||||
const modulus = parseColonArray(raw.TaperModulusArray);
|
||||
const rodType = parseColonArray(raw.RodTypeArray);
|
||||
const taperLen = Math.max(diam.length, length.length, modulus.length, rodType.length);
|
||||
const weight = parseColonArray(raw.TaperWeightArray);
|
||||
const mts = parseColonArray(raw.TaperMTSArray);
|
||||
const guideCounts = parseColonArray(raw.TaperGuidesCountArray);
|
||||
const rgWeights = parseColonArray(raw.RodGuideWeightArray);
|
||||
const rgTypePieces = textOf(raw.RodGuideTypeArray).split(":");
|
||||
const rodGuideSlotCount = Math.max(rgTypePieces.length, rgWeights.length, 1);
|
||||
/** Core rod-string stations (commercial XML often pads these together). */
|
||||
const coreTaperLen = Math.max(diam.length, length.length, modulus.length, rodType.length);
|
||||
const taperParallelSuffix = {
|
||||
weights: weight.length > coreTaperLen ? weight.slice(coreTaperLen) : [],
|
||||
mts: mts.length > coreTaperLen ? mts.slice(coreTaperLen) : [],
|
||||
guidesCounts: guideCounts.length > coreTaperLen ? guideCounts.slice(coreTaperLen) : []
|
||||
};
|
||||
const taper: TaperRow[] = [];
|
||||
for (let i = 0; i < taperLen; i += 1) {
|
||||
// Stop appending "zero" rows once we've passed the meaningful entries;
|
||||
// TaperCount is the authoritative limit but we keep all rows to preserve
|
||||
// round-trip exactly.
|
||||
for (let i = 0; i < coreTaperLen; i += 1) {
|
||||
const gc = guideCounts[i];
|
||||
const guidesEnabled = Number.isFinite(gc) && gc >= 0;
|
||||
const tok = (rgTypePieces[i] ?? "").trim();
|
||||
taper.push({
|
||||
diameter: diam[i] ?? 0,
|
||||
length: length[i] ?? 0,
|
||||
modulus: modulus[i] ?? 0,
|
||||
rodType: rodType[i] ?? 0
|
||||
rodType: rodType[i] ?? 0,
|
||||
weightLbfPerFt: weight[i] ?? 0,
|
||||
mtsLbf: mts[i] ?? 0,
|
||||
guidesEnabled,
|
||||
guideCount: guidesEnabled ? Math.max(0, Math.round(gc)) : 0,
|
||||
guideTypeToken: tok,
|
||||
rodGuideWeightLbfPerFt: rgWeights[i] ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,6 +148,12 @@ export function hydrateFromParsed(parsed: ParsedCase): CaseState {
|
||||
raw.OtherGuideFrictionRatio,
|
||||
model.otherGuideFrictionRatio ?? 1
|
||||
),
|
||||
|
||||
sinkerBarDiameter: numberOf(raw.SinkerBarDiameter, 0),
|
||||
sinkerBarLength: numberOf(raw.SinkerBarLength, 0),
|
||||
rodGuideSlotCount,
|
||||
taperParallelSuffix,
|
||||
|
||||
upStrokeDamping: numberOf(raw.UpStrokeDampingFactor, model.upStrokeDamping ?? 0),
|
||||
downStrokeDamping: numberOf(raw.DownStrokeDampingFactor, model.downStrokeDamping ?? 0),
|
||||
nonDimensionalFluidDamping: numberOf(
|
||||
|
||||
@@ -41,11 +41,22 @@ export type ParsedModel = {
|
||||
pumpingSpeedOption?: number;
|
||||
};
|
||||
|
||||
export type FieldTraceabilityEntry = {
|
||||
xmlKey: string;
|
||||
category: string;
|
||||
presentInXml: boolean;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type ParsedCase = {
|
||||
model: ParsedModel;
|
||||
unsupportedFields: string[];
|
||||
rawFields: Record<string, string | Record<string, unknown> | undefined>;
|
||||
warnings?: string[];
|
||||
fieldTraceability?: {
|
||||
schemaVersion: number;
|
||||
fields: FieldTraceabilityEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
export type CardPoint = {
|
||||
@@ -95,6 +106,7 @@ export type SolveResponse = {
|
||||
schemaVersion?: number;
|
||||
units?: string;
|
||||
parseWarnings?: string[];
|
||||
fieldTraceability?: ParsedCase["fieldTraceability"];
|
||||
surfaceCardQa?: Record<string, unknown> | null;
|
||||
fingerprint?: string;
|
||||
parsed: ParsedCase;
|
||||
@@ -126,6 +138,9 @@ export type SolveResponse = {
|
||||
}>;
|
||||
};
|
||||
fourier?: null | {
|
||||
harmonics?: number;
|
||||
residualRmsPolished?: number;
|
||||
residualRmsDownhole?: number;
|
||||
baselineName?: string;
|
||||
points?: number;
|
||||
residualRms?: number;
|
||||
|
||||
@@ -127,8 +127,10 @@ export function App() {
|
||||
workflow: runSettings.workflow,
|
||||
surfaceCard,
|
||||
options: {
|
||||
enableProfiles: true,
|
||||
enableDiagnosticsDetail: runSettings.workflow === "diagnostic"
|
||||
enableProfiles: runSettings.outputOptions.enableProfiles,
|
||||
enableDiagnosticsDetail: runSettings.outputOptions.enableDiagnosticsDetail,
|
||||
enableFourierBaseline: runSettings.outputOptions.enableFourierBaseline,
|
||||
fourierHarmonics: runSettings.outputOptions.fourierHarmonics
|
||||
}
|
||||
});
|
||||
setResult(resp);
|
||||
@@ -167,7 +169,7 @@ export function App() {
|
||||
<span className="app-logo" aria-hidden="true">
|
||||
⬛
|
||||
</span>
|
||||
Rods-Cursor — Case Editor & Solver
|
||||
Majic Rod Solver
|
||||
</div>
|
||||
<div className="app-header-meta">
|
||||
<span className="pill">{runSettings.solverModel.toUpperCase()}</span>
|
||||
@@ -223,7 +225,7 @@ export function App() {
|
||||
|
||||
<footer className="app-statusbar">
|
||||
<span>{statusMessage}</span>
|
||||
<span>Well: {store.state.wellName || "—"}</span>
|
||||
<span>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>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { CaseState } from "../../state/caseModel";
|
||||
import { DLS_BAD_SECTION_THRESHOLD } from "../../state/engineeringChecks";
|
||||
import {
|
||||
buildTrajectorySegments,
|
||||
interpolateAlongMd,
|
||||
type TrajectoryPoint3D,
|
||||
type TrajectorySegment
|
||||
type TrajectoryPoint3D
|
||||
} from "../../state/trajectoryMetrics";
|
||||
|
||||
type ProjectionMode = "perspective" | "orthographic";
|
||||
export type OverlayMode = "dls" | "sideLoad";
|
||||
export type OverlayMode = "depth" | "sideLoad";
|
||||
|
||||
export type Wellbore3DViewProps = {
|
||||
caseState: CaseState;
|
||||
@@ -22,10 +20,11 @@ export type Wellbore3DViewProps = {
|
||||
height?: number;
|
||||
};
|
||||
|
||||
function colorForDls(dls: number): string {
|
||||
if (dls >= DLS_BAD_SECTION_THRESHOLD) return "#ef4444";
|
||||
if (dls >= DLS_BAD_SECTION_THRESHOLD * 0.5) return "#f59e0b";
|
||||
return "#22c55e";
|
||||
function colorForDepth(md: number, mdMax: number): string {
|
||||
if (!Number.isFinite(md) || !Number.isFinite(mdMax) || mdMax <= 0) return "#38bdf8";
|
||||
const t = Math.max(0, Math.min(1, md / mdMax));
|
||||
const hue = 200 + t * 120;
|
||||
return `hsl(${hue}, 70%, 52%)`;
|
||||
}
|
||||
|
||||
function colorForSideLoad(value: number, max: number): string {
|
||||
@@ -89,7 +88,7 @@ function project(
|
||||
|
||||
export function Wellbore3DView({
|
||||
caseState,
|
||||
overlayMode = "dls",
|
||||
overlayMode = "depth",
|
||||
sideLoadProfile = null,
|
||||
highlightedSegmentIndex = null,
|
||||
onSegmentSelect,
|
||||
@@ -132,17 +131,14 @@ export function Wellbore3DView({
|
||||
const sideLoadMax = sideLoadProfile?.length
|
||||
? Math.max(...sideLoadProfile.filter((v) => Number.isFinite(v)), 0)
|
||||
: 0;
|
||||
return { segments, bounds, rodLength, pumpPoint, sideLoadMax };
|
||||
const mdMax = segments[segments.length - 1].b.md;
|
||||
return { segments, bounds, rodLength, pumpPoint, sideLoadMax, mdMax };
|
||||
}, [caseState, sideLoadProfile]);
|
||||
|
||||
if (!geom) {
|
||||
return <p className="panel-note">Need at least 2 survey stations to render 3D wellbore.</p>;
|
||||
}
|
||||
|
||||
const maxDls = Math.max(...geom.segments.map((segment) => segment.dls), 0);
|
||||
const highDlsCount = geom.segments.filter(
|
||||
(segment) => segment.dls >= DLS_BAD_SECTION_THRESHOLD
|
||||
).length;
|
||||
const totalLen = geom.segments[geom.segments.length - 1].b.md;
|
||||
|
||||
return (
|
||||
@@ -258,10 +254,11 @@ export function Wellbore3DView({
|
||||
sideLoadProfile && sideLoadProfile.length
|
||||
? sideLoadProfile[Math.min(idx, sideLoadProfile.length - 1)] ?? 0
|
||||
: 0;
|
||||
const midMd = (segment.a.md + segment.b.md) * 0.5;
|
||||
const stroke =
|
||||
overlayMode === "sideLoad"
|
||||
? colorForSideLoad(sideLoad, geom.sideLoadMax)
|
||||
: colorForDls(segment.dls);
|
||||
: colorForDepth(midMd, geom.mdMax);
|
||||
const active = highlightedSegmentIndex === idx;
|
||||
return (
|
||||
<line
|
||||
@@ -315,28 +312,21 @@ export function Wellbore3DView({
|
||||
)}
|
||||
</svg>
|
||||
<div className="wellbore-legend">
|
||||
{overlayMode === "dls" ? (
|
||||
<>
|
||||
<span><i style={{ background: "#22c55e" }} />Low DLS (< {(DLS_BAD_SECTION_THRESHOLD * 0.5).toFixed(1)})</span>
|
||||
<span><i style={{ background: "#f59e0b" }} />Moderate DLS</span>
|
||||
<span><i style={{ background: "#ef4444" }} />Bad section DLS (≥ {DLS_BAD_SECTION_THRESHOLD})</span>
|
||||
</>
|
||||
) : (
|
||||
{overlayMode === "sideLoad" ? (
|
||||
<>
|
||||
<span><i style={{ background: "#22c55e" }} />Low side-load risk</span>
|
||||
<span><i style={{ background: "#f59e0b" }} />Moderate side-load risk</span>
|
||||
<span><i style={{ background: "#ef4444" }} />High side-load risk</span>
|
||||
</>
|
||||
) : (
|
||||
<span><i style={{ background: "linear-gradient(90deg,#38bdf8,#d946ef)" }} />Trajectory by MD (hue)</span>
|
||||
)}
|
||||
<span><i style={{ background: "linear-gradient(90deg,#38bdf8,#d946ef)" }} />Rod string gradient</span>
|
||||
</div>
|
||||
<div className="wellbore-kpis">
|
||||
<span>Max DLS: {maxDls.toFixed(2)} deg/100</span>
|
||||
<span>Bad-DLS segments: {highDlsCount}</span>
|
||||
<span>Total MD: {totalLen.toFixed(1)}</span>
|
||||
<span>Pump MD: {caseState.pumpDepth.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,20 @@ describe("Wellbore3DView controls", () => {
|
||||
{ md: 500, inc: 15, azi: 35 },
|
||||
{ md: 1000, inc: 30, azi: 65 }
|
||||
],
|
||||
taper: [{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }]
|
||||
taper: [
|
||||
{
|
||||
diameter: 19.05,
|
||||
length: 1000,
|
||||
modulus: 30.5,
|
||||
rodType: 3,
|
||||
weightLbfPerFt: 1.634,
|
||||
mtsLbf: 792_897,
|
||||
guidesEnabled: false,
|
||||
guideCount: 0,
|
||||
guideTypeToken: "",
|
||||
rodGuideWeightLbfPerFt: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
render(<Wellbore3DView caseState={state} />);
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import {
|
||||
estimateTubingGradientPaMForMetric,
|
||||
estimateTubingGradientPsiPerFt
|
||||
} from "../../state/tubingGradientEstimate";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function FluidTab({ store }: Props) {
|
||||
const { state, update } = store;
|
||||
|
||||
const estimated = useMemo(
|
||||
() => ({
|
||||
psiPerFt: estimateTubingGradientPsiPerFt(state),
|
||||
paM: estimateTubingGradientPaMForMetric(state)
|
||||
}),
|
||||
[state.waterCut, state.waterSpecGravity, state.fluidLevelOilGravity]
|
||||
);
|
||||
|
||||
function applyEstimate() {
|
||||
if (state.unitsSelection === 1) {
|
||||
update("tubingGradient", estimated.paM);
|
||||
} else {
|
||||
update("tubingGradient", estimated.psiPerFt);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Fluid Properties">
|
||||
@@ -42,7 +64,11 @@ export function FluidTab({ store }: Props) {
|
||||
<Row
|
||||
label="Tubing Gradient"
|
||||
htmlFor="tubingGrad"
|
||||
hint="psi/ft in imperial units; converted to Pa/m internally"
|
||||
hint={
|
||||
state.unitsSelection === 1
|
||||
? "Pa/m when SI (UnitsSelection=1); from XML"
|
||||
: "psi/ft imperial oilfield; converted to Pa/m in the API"
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
id="tubingGrad"
|
||||
@@ -51,6 +77,18 @@ export function FluidTab({ store }: Props) {
|
||||
onChange={(v) => update("tubingGradient", v)}
|
||||
/>
|
||||
</Row>
|
||||
<div className="button-row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
<button type="button" className="btn" onClick={applyEstimate}>
|
||||
Apply liquid hydrostatic estimate
|
||||
</button>
|
||||
<span className="panel-note">
|
||||
Heuristic bulk-liquid ρ(water cut, API, water SG) → gradient ≈{" "}
|
||||
{state.unitsSelection === 1
|
||||
? `${estimated.paM.toFixed(1)} Pa/m`
|
||||
: `${estimated.psiPerFt.toFixed(4)} psi/ft`}
|
||||
. Not wired into the C solve yet (see COMPUTE_PLAN).
|
||||
</span>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { SelectField } from "../common/SelectField";
|
||||
import {
|
||||
PUMP_PLUNGER_INCH_OPTIONS,
|
||||
formatPumpDiameterDual,
|
||||
pumpDiameterMmFromInches
|
||||
} from "../../state/unitsDisplay";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function PumpTab({ store }: Props) {
|
||||
const { state, update } = store;
|
||||
|
||||
const pumpOptions = useMemo(() => {
|
||||
const mmVals = PUMP_PLUNGER_INCH_OPTIONS.map((inchVal) => pumpDiameterMmFromInches(inchVal));
|
||||
const opts = PUMP_PLUNGER_INCH_OPTIONS.map((inchVal, i) => ({
|
||||
value: mmVals[i],
|
||||
label: formatPumpDiameterDual(mmVals[i], state.unitsSelection)
|
||||
}));
|
||||
const known = opts.some((o) => Math.abs(o.value - state.pumpDiameter) < 0.001);
|
||||
if (!known && state.pumpDiameter > 0) {
|
||||
opts.unshift({
|
||||
value: state.pumpDiameter,
|
||||
label: formatPumpDiameterDual(state.pumpDiameter, state.unitsSelection)
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [state.pumpDiameter, state.unitsSelection]);
|
||||
|
||||
return (
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Pump Geometry">
|
||||
<Row
|
||||
label="Plunger Diameter"
|
||||
label="Plunger diameter"
|
||||
htmlFor="plungerDiam"
|
||||
hint="mm in base-case XML (converted to m if > 2)"
|
||||
hint="Stored as mm in the case file when above 2 (same convention as rod OD)."
|
||||
>
|
||||
<NumberField
|
||||
<SelectField
|
||||
id="plungerDiam"
|
||||
value={state.pumpDiameter}
|
||||
step={0.25}
|
||||
options={pumpOptions}
|
||||
onChange={(v) => update("pumpDiameter", v)}
|
||||
ariaLabel="Pump plunger diameter"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Pump Friction" htmlFor="pumpFric" hint="lbf (imperial)">
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Options, AlignedData } from "uplot";
|
||||
import type { SolveResponse, SolverOutput } from "../../types";
|
||||
import type { CaseState } from "../../state/caseModel";
|
||||
import type { EngineeringChecks } from "../../state/engineeringChecks";
|
||||
import { DLS_BAD_SECTION_THRESHOLD } from "../../state/engineeringChecks";
|
||||
import { buildTrajectorySegments } from "../../state/trajectoryMetrics";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { UPlotChart } from "../common/UPlotChart";
|
||||
@@ -88,17 +87,9 @@ export function ResultsTab({
|
||||
const primary = result?.solver;
|
||||
const fea = result?.solvers?.fea ?? null;
|
||||
const fdm = result?.solvers?.fdm ?? primary ?? null;
|
||||
const [overlayMode, setOverlayMode] = useState<OverlayMode>("dls");
|
||||
const [overlayMode, setOverlayMode] = useState<OverlayMode>("depth");
|
||||
const [selectedSegment, setSelectedSegment] = useState<number | null>(null);
|
||||
const [badOnly, setBadOnly] = useState(false);
|
||||
const trajectorySegments = useMemo(() => buildTrajectorySegments(caseState.survey), [caseState.survey]);
|
||||
const filteredSegments = useMemo(
|
||||
() =>
|
||||
badOnly
|
||||
? trajectorySegments.filter((segment) => segment.dls >= DLS_BAD_SECTION_THRESHOLD)
|
||||
: trajectorySegments,
|
||||
[badOnly, trajectorySegments]
|
||||
);
|
||||
const sideLoadProfile = primary?.profiles?.sideLoadProfile ?? null;
|
||||
const pumpDiag = useMemo(() => computePumpPlacement(caseState), [caseState]);
|
||||
|
||||
@@ -173,17 +164,17 @@ export function ResultsTab({
|
||||
)}
|
||||
<Fieldset legend="3D Wellbore / Rod String / Pump">
|
||||
<p className="panel-note">
|
||||
Tubing trajectory is colored by dogleg severity (DLS). Rod string is overlaid with a
|
||||
Tubing trajectory is colored by measured depth (hue). Rod string is overlaid with a
|
||||
depth gradient, and pump location is marked in red.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${overlayMode === "dls" ? "btn-primary" : ""}`}
|
||||
onClick={() => setOverlayMode("dls")}
|
||||
aria-pressed={overlayMode === "dls"}
|
||||
className={`btn ${overlayMode === "depth" ? "btn-primary" : ""}`}
|
||||
onClick={() => setOverlayMode("depth")}
|
||||
aria-pressed={overlayMode === "depth"}
|
||||
>
|
||||
Overlay: DLS
|
||||
Overlay: Depth
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -247,19 +238,11 @@ export function ResultsTab({
|
||||
svgId="wellbore-3d-svg"
|
||||
/>
|
||||
</Fieldset>
|
||||
<Fieldset legend="Trajectory Analytics (Segment DLS)">
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${badOnly ? "btn-primary" : ""}`}
|
||||
onClick={() => setBadOnly((v) => !v)}
|
||||
>
|
||||
{badOnly ? "Showing bad segments only" : "Show only bad segments"}
|
||||
</button>
|
||||
<span className="panel-note" style={{ marginLeft: "auto" }}>
|
||||
Click row or 3D segment to cross-highlight.
|
||||
</span>
|
||||
</div>
|
||||
<Fieldset legend="Trajectory Analytics">
|
||||
<p className="panel-note" style={{ marginBottom: 8 }}>
|
||||
Click a row or 3D segment to cross-highlight. DLS is shown numerically only (deg per
|
||||
100 ft MD).
|
||||
</p>
|
||||
<div className="table-scroll" style={{ maxHeight: 250 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
@@ -269,18 +252,10 @@ export function ResultsTab({
|
||||
<th>MD end</th>
|
||||
<th>ΔMD</th>
|
||||
<th>DLS (deg/100)</th>
|
||||
<th>Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSegments.map((segment) => {
|
||||
const severity =
|
||||
segment.dls >= DLS_BAD_SECTION_THRESHOLD
|
||||
? "bad"
|
||||
: segment.dls >= DLS_BAD_SECTION_THRESHOLD * 0.5
|
||||
? "moderate"
|
||||
: "low";
|
||||
return (
|
||||
{trajectorySegments.map((segment) => (
|
||||
<tr
|
||||
key={segment.index}
|
||||
className={selectedSegment === segment.index ? "row-selected" : ""}
|
||||
@@ -301,13 +276,11 @@ export function ResultsTab({
|
||||
<td>{segment.b.md.toFixed(1)}</td>
|
||||
<td>{segment.dMd.toFixed(1)}</td>
|
||||
<td>{segment.dls.toFixed(2)}</td>
|
||||
<td>{severity}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!filteredSegments.length && (
|
||||
))}
|
||||
{!trajectorySegments.length && (
|
||||
<tr>
|
||||
<td colSpan={6} className="empty-row">No trajectory segments to display.</td>
|
||||
<td colSpan={5} className="empty-row">No trajectory segments to display.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -383,8 +356,122 @@ export function ResultsTab({
|
||||
) : (
|
||||
<p className="panel-note">No card data in response.</p>
|
||||
)}
|
||||
{primary?.fourierBaseline && primary.fourierBaseline.card?.length ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="panel-note">
|
||||
Fourier baseline ({primary.fourierBaseline.harmonics} harmonics): RMS polished{" "}
|
||||
{primary.fourierBaseline.residualRmsPolished?.toFixed(1)} N · RMS downhole{" "}
|
||||
{primary.fourierBaseline.residualRmsDownhole?.toFixed(1)} N
|
||||
</p>
|
||||
<UPlotChart
|
||||
height={260}
|
||||
data={(() => {
|
||||
const n = Math.min(primary.card.length, primary.fourierBaseline.card.length);
|
||||
const pos = primary.card.slice(0, n).map((p) => p.position);
|
||||
const pol = primary.card.slice(0, n).map((p) => p.polishedLoad);
|
||||
const four = primary.fourierBaseline.card.slice(0, n).map((p) => p.polishedLoad);
|
||||
return [pos, pol, four];
|
||||
})()}
|
||||
options={{
|
||||
width: 800,
|
||||
height: 260,
|
||||
scales: { x: { time: false } },
|
||||
axes: [
|
||||
{ label: "Position (m)", stroke: "#cbd5f5" },
|
||||
{ label: "Load (N)", stroke: "#cbd5f5" }
|
||||
],
|
||||
series: [
|
||||
{ label: "Position" },
|
||||
{ label: "Polished (FDM)", stroke: "#f59e0b", width: 2 },
|
||||
{ label: "Polished (Fourier)", stroke: "#a78bfa", width: 1.5, dash: [8, 4] }
|
||||
],
|
||||
legend: { show: true }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Fieldset>
|
||||
|
||||
{primary?.profiles && (
|
||||
<Fieldset legend="Profiles (trajectory / side-load / friction)">
|
||||
<div className="kpi-grid">
|
||||
<Kpi label="Profile nodes" value={String(primary.profiles.nodeCount)} />
|
||||
<Kpi
|
||||
label="Max |side load| (N)"
|
||||
value={
|
||||
primary.profiles.sideLoadProfile.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
...primary.profiles.sideLoadProfile.map((v) => Math.abs(v))
|
||||
).toFixed(0)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Kpi
|
||||
label="Max |friction| (N)"
|
||||
value={
|
||||
primary.profiles.frictionProfile.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
...primary.profiles.frictionProfile.map((v) => Math.abs(v))
|
||||
).toFixed(0)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{primary?.diagnostics &&
|
||||
primary.diagnostics.chamberPressurePa.length > 0 &&
|
||||
primary.diagnostics.gasFraction.length > 0 && (
|
||||
<Fieldset legend="Diagnostics (valve / chamber / gas)">
|
||||
<div className="kpi-grid">
|
||||
<Kpi
|
||||
label="Valve samples"
|
||||
value={`${primary.diagnostics.valveStates.length} points`}
|
||||
/>
|
||||
<Kpi
|
||||
label="Chamber P range (Pa)"
|
||||
value={`${Math.min(...primary.diagnostics.chamberPressurePa).toFixed(0)} … ${Math.max(...primary.diagnostics.chamberPressurePa).toFixed(0)}`}
|
||||
/>
|
||||
<Kpi
|
||||
label="Gas fraction range"
|
||||
value={`${Math.min(...primary.diagnostics.gasFraction).toFixed(3)} … ${Math.max(...primary.diagnostics.gasFraction).toFixed(3)}`}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{result.fieldTraceability?.fields?.length ? (
|
||||
<Fieldset legend="MVP field traceability (static map)">
|
||||
<p className="panel-note">
|
||||
Categories mirror <code>docs/engineering/field-traceability.md</code> (
|
||||
physics, metadata, parseCalibration, payloadInactive, parsedUnused).
|
||||
</p>
|
||||
<div className="table-scroll" style={{ maxHeight: 280 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>XML field</th>
|
||||
<th>Category</th>
|
||||
<th>In file</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.fieldTraceability.fields.map((row) => (
|
||||
<tr key={row.xmlKey} title={row.notes || undefined}>
|
||||
<td className="mono">{row.xmlKey}</td>
|
||||
<td>{row.category}</td>
|
||||
<td>{row.presentInXml ? "yes" : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Fieldset>
|
||||
) : null}
|
||||
|
||||
{result.comparison && (
|
||||
<Fieldset legend="FDM vs FEA Comparison">
|
||||
<div className="kpi-grid">
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import type { TaperRow } from "../../state/caseModel";
|
||||
import { lookupRodCatalog, ROD_DIAMETER_MM_OPTIONS } from "../../state/rodCatalog";
|
||||
import { rodJointLengthNative } from "../../state/rodJointLength";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { SelectField } from "../common/SelectField";
|
||||
import { Row } from "../common/Row";
|
||||
import { CheckboxField } from "../common/CheckboxField";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
@@ -13,36 +18,107 @@ const ROD_TYPE_OPTIONS = [
|
||||
{ value: 3, label: "3 — fiberglass" }
|
||||
];
|
||||
|
||||
const GUIDE_TYPE_OPTIONS = [
|
||||
{ value: "", label: "None" },
|
||||
{ value: "M", label: "M — molded" },
|
||||
{ value: "N", label: "N — narrow / other" }
|
||||
];
|
||||
|
||||
function makeTaperRow(
|
||||
diameter: number,
|
||||
length: number,
|
||||
modulus: number,
|
||||
rodType: number,
|
||||
guideTypeToken: string
|
||||
): TaperRow {
|
||||
const cat = lookupRodCatalog(rodType, diameter);
|
||||
return {
|
||||
diameter,
|
||||
length,
|
||||
modulus,
|
||||
rodType,
|
||||
weightLbfPerFt: cat.weightLbfPerFt,
|
||||
mtsLbf: cat.mtsLbf,
|
||||
guidesEnabled: false,
|
||||
guideCount: 0,
|
||||
guideTypeToken,
|
||||
rodGuideWeightLbfPerFt: 0
|
||||
};
|
||||
}
|
||||
|
||||
function diameterOptionsFor(rowDiameter: number) {
|
||||
const base = [...ROD_DIAMETER_MM_OPTIONS];
|
||||
if (!base.some((o) => Math.abs(o.value - rowDiameter) < 0.001)) {
|
||||
base.unshift({ value: rowDiameter, label: `Current (${rowDiameter})` });
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function RodStringTab({ store }: Props) {
|
||||
const { state, addTaperRow, removeTaperRow, updateTaperRow, setTaper } = store;
|
||||
const { state, addTaperRow, removeTaperRow, updateTaperRow, setTaper, update } = store;
|
||||
const [taperLengthMode, setTaperLengthMode] = useState<"length" | "count">("length");
|
||||
|
||||
const depthUnit = state.unitsSelection === 1 ? "m" : "ft";
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const nonZero = state.taper.filter(
|
||||
(row) => row.diameter > 0 && row.length > 0
|
||||
);
|
||||
const nonZero = state.taper.filter((row) => row.diameter > 0 && row.length > 0);
|
||||
const length = nonZero.reduce((acc, r) => acc + r.length, 0);
|
||||
return { sections: nonZero.length, length };
|
||||
}, [state.taper]);
|
||||
|
||||
const applyDiameterOrType = useCallback(
|
||||
(index: number, patch: Partial<TaperRow>) => {
|
||||
const row = state.taper[index];
|
||||
if (!row) return;
|
||||
const next = { ...row, ...patch };
|
||||
const dMm = next.diameter > 2 ? next.diameter : next.diameter * 25.4;
|
||||
const cat = lookupRodCatalog(next.rodType, dMm);
|
||||
updateTaperRow(index, {
|
||||
...patch,
|
||||
weightLbfPerFt: cat.weightLbfPerFt,
|
||||
mtsLbf: cat.mtsLbf,
|
||||
modulus: next.modulus >= 1e8 ? next.modulus : cat.modulusMpsi
|
||||
});
|
||||
},
|
||||
[state.taper, updateTaperRow]
|
||||
);
|
||||
|
||||
function loadDefaultString() {
|
||||
setTaper([
|
||||
{ diameter: 22.225, length: 86, modulus: 30.5, rodType: 3 },
|
||||
{ diameter: 19.05, length: 86, modulus: 30.5, rodType: 3 },
|
||||
{ diameter: 38.1, length: 10, modulus: 30.5, rodType: 2 },
|
||||
{ diameter: 19.05, length: 36, modulus: 30.5, rodType: 3 },
|
||||
{ diameter: 19.05, length: 9, modulus: 30.5, rodType: 3 }
|
||||
makeTaperRow(22.225, 86, 30.5, 3, "M"),
|
||||
makeTaperRow(19.05, 86, 30.5, 3, "M"),
|
||||
makeTaperRow(38.1, 10, 30.5, 2, "M"),
|
||||
makeTaperRow(19.05, 36, 30.5, 3, "N"),
|
||||
makeTaperRow(19.05, 9, 30.5, 3, "M")
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fieldset legend="Rod String Taper Sections">
|
||||
<p className="panel-note">
|
||||
Define taper sections from the top (surface) to the bottom (pump). The
|
||||
solver treats diameter values > 2 as millimetres and converts to SI.
|
||||
Modulus ≥ 1e8 is treated as Pa; otherwise as Mpsi.
|
||||
Diameter uses the case-file convention: values above 2 are millimetres; smaller values are
|
||||
inches. Taper length is in {depthUnit} for the current units selection (imperial oilfield
|
||||
uses feet). Weight and MTS presets are{" "}
|
||||
<abbr title="Approximate catalog values for workflow only">heuristic</abbr> — verify for
|
||||
your vendor tables.
|
||||
</p>
|
||||
|
||||
<div className="button-row">
|
||||
<span className="panel-note">Section length:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${taperLengthMode === "length" ? "btn-primary" : ""}`}
|
||||
onClick={() => setTaperLengthMode("length")}
|
||||
>
|
||||
Length ({depthUnit})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${taperLengthMode === "count" ? "btn-primary" : ""}`}
|
||||
onClick={() => setTaperLengthMode("count")}
|
||||
>
|
||||
Joint count
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => addTaperRow()}>
|
||||
Add Section
|
||||
</button>
|
||||
@@ -53,8 +129,8 @@ export function RodStringTab({ store }: Props) {
|
||||
Load Base-Case String
|
||||
</button>
|
||||
<span className="panel-note" style={{ marginLeft: "auto" }}>
|
||||
{totals.sections} active section{totals.sections === 1 ? "" : "s"} ·
|
||||
total length {totals.length.toFixed(1)}
|
||||
{totals.sections} active section{totals.sections === 1 ? "" : "s"} · total rod length{" "}
|
||||
{totals.length.toFixed(1)} {depthUnit}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -62,31 +138,52 @@ export function RodStringTab({ store }: Props) {
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>#</th>
|
||||
<th style={{ width: 36 }}>#</th>
|
||||
<th>Diameter</th>
|
||||
<th>Length</th>
|
||||
<th>{taperLengthMode === "length" ? `Length (${depthUnit})` : "Joints"}</th>
|
||||
<th>Modulus</th>
|
||||
<th>Rod Type</th>
|
||||
<th style={{ width: 60 }}></th>
|
||||
<th>Rod type</th>
|
||||
<th>Wt (lb/ft)</th>
|
||||
<th>MTS (lbf)</th>
|
||||
<th>Guide</th>
|
||||
<th># guides</th>
|
||||
<th>Guide type</th>
|
||||
<th>Rod guide wt</th>
|
||||
<th style={{ width: 56 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.taper.map((row, i) => (
|
||||
{state.taper.map((row, i) => {
|
||||
const jNative = rodJointLengthNative(state.rawFields, row.rodType, state.unitsSelection);
|
||||
const isSinker = Math.round(row.rodType) === 2;
|
||||
const countDisplay =
|
||||
isSinker || jNative <= 0 ? row.length : row.length / jNative;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
<NumberField
|
||||
<SelectField
|
||||
value={row.diameter}
|
||||
onChange={(v) => updateTaperRow(i, { diameter: v })}
|
||||
options={diameterOptionsFor(row.diameter)}
|
||||
onChange={(v) => applyDiameterOrType(i, { diameter: v })}
|
||||
ariaLabel={`Taper ${i + 1} diameter`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{taperLengthMode === "length" || isSinker ? (
|
||||
<NumberField
|
||||
value={row.length}
|
||||
onChange={(v) => updateTaperRow(i, { length: v })}
|
||||
ariaLabel={`Taper ${i + 1} length`}
|
||||
/>
|
||||
) : (
|
||||
<NumberField
|
||||
value={countDisplay}
|
||||
step={0.01}
|
||||
onChange={(v) => updateTaperRow(i, { length: v * jNative })}
|
||||
ariaLabel={`Taper ${i + 1} joint count`}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
@@ -100,7 +197,63 @@ export function RodStringTab({ store }: Props) {
|
||||
<SelectField
|
||||
value={row.rodType}
|
||||
options={ROD_TYPE_OPTIONS}
|
||||
onChange={(v) => updateTaperRow(i, { rodType: v })}
|
||||
onChange={(v) => applyDiameterOrType(i, { rodType: v })}
|
||||
ariaLabel={`Taper ${i + 1} rod type`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.weightLbfPerFt}
|
||||
step={0.001}
|
||||
onChange={(v) => updateTaperRow(i, { weightLbfPerFt: v })}
|
||||
ariaLabel={`Taper ${i + 1} weight lb per ft`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.mtsLbf}
|
||||
step={1}
|
||||
onChange={(v) => updateTaperRow(i, { mtsLbf: v })}
|
||||
ariaLabel={`Taper ${i + 1} MTS lbf`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<CheckboxField
|
||||
id={`taper-${i}-guides`}
|
||||
label=""
|
||||
checked={row.guidesEnabled}
|
||||
onChange={(checked) =>
|
||||
updateTaperRow(i, {
|
||||
guidesEnabled: checked,
|
||||
guideCount: checked ? Math.max(1, row.guideCount || 1) : 0
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.guideCount}
|
||||
step={1}
|
||||
min={0}
|
||||
disabled={!row.guidesEnabled}
|
||||
onChange={(v) => updateTaperRow(i, { guideCount: Math.max(0, Math.round(v)) })}
|
||||
ariaLabel={`Taper ${i + 1} guide count`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<SelectField
|
||||
value={row.guideTypeToken}
|
||||
options={GUIDE_TYPE_OPTIONS}
|
||||
onChange={(v) => updateTaperRow(i, { guideTypeToken: v })}
|
||||
ariaLabel={`Taper ${i + 1} guide type`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.rodGuideWeightLbfPerFt}
|
||||
step={0.01}
|
||||
onChange={(v) => updateTaperRow(i, { rodGuideWeightLbfPerFt: v })}
|
||||
ariaLabel={`Taper ${i + 1} rod guide weight`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
@@ -114,10 +267,11 @@ export function RodStringTab({ store }: Props) {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{state.taper.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="empty-row">
|
||||
<td colSpan={12} className="empty-row">
|
||||
No taper sections. Add rows or load the base-case string.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -125,6 +279,31 @@ export function RodStringTab({ store }: Props) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Fieldset legend="Sinker bar (XML)">
|
||||
<p className="panel-note">
|
||||
Rod type 2 rows define the string; these fields map to{" "}
|
||||
<code>SinkerBarDiameter</code> / <code>SinkerBarLength</code>.
|
||||
</p>
|
||||
<div className="tab-grid two">
|
||||
<Row label="SinkerBarDiameter" htmlFor="sinkerD">
|
||||
<NumberField
|
||||
id="sinkerD"
|
||||
value={state.sinkerBarDiameter}
|
||||
step={0.001}
|
||||
onChange={(v) => update("sinkerBarDiameter", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="SinkerBarLength" htmlFor="sinkerL">
|
||||
<NumberField
|
||||
id="sinkerL"
|
||||
value={state.sinkerBarLength}
|
||||
step={0.01}
|
||||
onChange={(v) => update("sinkerBarLength", v)}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { RadioGroup } from "../common/RadioGroup";
|
||||
import { CheckboxField } from "../common/CheckboxField";
|
||||
|
||||
type Props = {
|
||||
store: CaseStore;
|
||||
@@ -89,6 +90,63 @@ export function SolverTab({
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Heavy solver JSON (API options)">
|
||||
<p className="panel-note">
|
||||
When disabled, the API returns <code>null</code> for <code>profiles</code> /{" "}
|
||||
<code>diagnostics</code> / Fourier blocks to keep payloads small. Enable when you need
|
||||
plots or QA detail.
|
||||
</p>
|
||||
<div className="button-row" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<CheckboxField
|
||||
id="optProfiles"
|
||||
label="enableProfiles (trajectory + side-load + friction profiles)"
|
||||
checked={runSettings.outputOptions.enableProfiles}
|
||||
onChange={(checked) =>
|
||||
onRunSettingsChange({
|
||||
...runSettings,
|
||||
outputOptions: { ...runSettings.outputOptions, enableProfiles: checked }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="optDiag"
|
||||
label="enableDiagnosticsDetail (valve / chamber / gas arrays)"
|
||||
checked={runSettings.outputOptions.enableDiagnosticsDetail}
|
||||
onChange={(checked) =>
|
||||
onRunSettingsChange({
|
||||
...runSettings,
|
||||
outputOptions: { ...runSettings.outputOptions, enableDiagnosticsDetail: checked }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="optFour"
|
||||
label="enableFourierBaseline"
|
||||
checked={runSettings.outputOptions.enableFourierBaseline}
|
||||
onChange={(checked) =>
|
||||
onRunSettingsChange({
|
||||
...runSettings,
|
||||
outputOptions: { ...runSettings.outputOptions, enableFourierBaseline: checked }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Row label="fourierHarmonics">
|
||||
<NumberField
|
||||
value={runSettings.outputOptions.fourierHarmonics}
|
||||
step={1}
|
||||
min={1}
|
||||
max={64}
|
||||
onChange={(v) =>
|
||||
onRunSettingsChange({
|
||||
...runSettings,
|
||||
outputOptions: { ...runSettings.outputOptions, fourierHarmonics: Math.round(v) }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Damping">
|
||||
<Row label="Up-stroke damping">
|
||||
|
||||
@@ -5,7 +5,8 @@ import { NumberField } from "../common/NumberField";
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function TrajectoryTab({ store }: Props) {
|
||||
const { state, addSurveyRow, removeSurveyRow, updateSurveyRow, setSurvey } = store;
|
||||
const { state, addSurveyRow, insertSurveyRowBelow, removeSurveyRow, updateSurveyRow, setSurvey } =
|
||||
store;
|
||||
|
||||
function loadVertical() {
|
||||
const depth = state.pumpDepth || 1727;
|
||||
@@ -65,7 +66,7 @@ export function TrajectoryTab({ store }: Props) {
|
||||
<th>MD</th>
|
||||
<th>Inclination (°)</th>
|
||||
<th>Azimuth (°)</th>
|
||||
<th style={{ width: 60 }}></th>
|
||||
<th style={{ width: 120 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -100,6 +101,15 @@ export function TrajectoryTab({ store }: Props) {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="button-row" style={{ gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => insertSurveyRowBelow(i)}
|
||||
title="Insert a station after this row"
|
||||
>
|
||||
Insert below
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
@@ -108,12 +118,13 @@ export function TrajectoryTab({ store }: Props) {
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{state.survey.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="empty-row">
|
||||
<td colSpan={6} className="empty-row">
|
||||
No survey stations. Add rows or load a preset.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static void print_json_output(const SolverOutputs *outputs) {
|
||||
static void print_json_output(const SolverInputs *inputs, const SolverOutputs *outputs) {
|
||||
printf("{\n");
|
||||
printf(" \"pointCount\": %d,\n", outputs->point_count);
|
||||
printf(" \"maxPolishedLoad\": %.6f,\n", outputs->max_polished_load);
|
||||
@@ -55,6 +55,7 @@ static void print_json_output(const SolverOutputs *outputs) {
|
||||
printf("]\n");
|
||||
printf(" },\n");
|
||||
|
||||
if (inputs->enable_profiles) {
|
||||
printf(" \"profiles\": {\n");
|
||||
printf(" \"nodeCount\": %d,\n", outputs->profile_node_count);
|
||||
printf(" \"trajectory3D\": [");
|
||||
@@ -80,7 +81,11 @@ static void print_json_output(const SolverOutputs *outputs) {
|
||||
}
|
||||
printf("]\n");
|
||||
printf(" },\n");
|
||||
} else {
|
||||
printf(" \"profiles\": null,\n");
|
||||
}
|
||||
|
||||
if (inputs->enable_diagnostics_detail) {
|
||||
printf(" \"diagnostics\": {\n");
|
||||
printf(" \"valveStates\": [");
|
||||
for (int i = 0; i < outputs->point_count; i++) {
|
||||
@@ -103,9 +108,12 @@ static void print_json_output(const SolverOutputs *outputs) {
|
||||
}
|
||||
printf("]\n");
|
||||
printf(" },\n");
|
||||
} else {
|
||||
printf(" \"diagnostics\": null,\n");
|
||||
}
|
||||
|
||||
printf(" \"fourierBaseline\": ");
|
||||
if (outputs->fourier_harmonics_used > 0) {
|
||||
if (inputs->enable_fourier_baseline && outputs->fourier_harmonics_used > 0) {
|
||||
printf("{\"harmonics\": %d, \"residualRmsPolished\": %.6f, \"residualRmsDownhole\": %.6f, \"card\": [",
|
||||
outputs->fourier_harmonics_used,
|
||||
outputs->fourier_residual_rms_polished,
|
||||
@@ -172,6 +180,6 @@ int main(int argc, char **argv) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
print_json_output(&outputs);
|
||||
print_json_output(&inputs, &outputs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static void print_json_output(const SolverOutputs *outputs) {
|
||||
static void print_json_output(const SolverInputs *inputs, const SolverOutputs *outputs) {
|
||||
printf("{\n");
|
||||
printf(" \"pointCount\": %d,\n", outputs->point_count);
|
||||
printf(" \"maxPolishedLoad\": %.6f,\n", outputs->max_polished_load);
|
||||
@@ -55,6 +55,7 @@ static void print_json_output(const SolverOutputs *outputs) {
|
||||
printf("]\n");
|
||||
printf(" },\n");
|
||||
|
||||
if (inputs->enable_profiles) {
|
||||
printf(" \"profiles\": {\n");
|
||||
printf(" \"nodeCount\": %d,\n", outputs->profile_node_count);
|
||||
printf(" \"trajectory3D\": [");
|
||||
@@ -80,7 +81,11 @@ static void print_json_output(const SolverOutputs *outputs) {
|
||||
}
|
||||
printf("]\n");
|
||||
printf(" },\n");
|
||||
} else {
|
||||
printf(" \"profiles\": null,\n");
|
||||
}
|
||||
|
||||
if (inputs->enable_diagnostics_detail) {
|
||||
printf(" \"diagnostics\": {\n");
|
||||
printf(" \"valveStates\": [");
|
||||
for (int i = 0; i < outputs->point_count; i++) {
|
||||
@@ -103,9 +108,12 @@ static void print_json_output(const SolverOutputs *outputs) {
|
||||
}
|
||||
printf("]\n");
|
||||
printf(" },\n");
|
||||
} else {
|
||||
printf(" \"diagnostics\": null,\n");
|
||||
}
|
||||
|
||||
printf(" \"fourierBaseline\": ");
|
||||
if (outputs->fourier_harmonics_used > 0) {
|
||||
if (inputs->enable_fourier_baseline && outputs->fourier_harmonics_used > 0) {
|
||||
printf("{\"harmonics\": %d, \"residualRmsPolished\": %.6f, \"residualRmsDownhole\": %.6f, \"card\": [",
|
||||
outputs->fourier_harmonics_used,
|
||||
outputs->fourier_residual_rms_polished,
|
||||
@@ -165,6 +173,6 @@ int main(int argc, char **argv) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
print_json_output(&outputs);
|
||||
print_json_output(&inputs, &outputs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,26 @@ static int test_zero_input_bounded(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Gas fraction and chamber pressure invariants after valve/gas stepping */
|
||||
static int test_diagnostics_invariants(void) {
|
||||
SolverInputs inputs;
|
||||
fill_base_inputs(&inputs);
|
||||
SolverOutputs outputs;
|
||||
if (solver_run_fdm(&inputs, &outputs) != 0) {
|
||||
return 1;
|
||||
}
|
||||
for (int i = 0; i < outputs.point_count; i++) {
|
||||
const double g = outputs.gas_fraction[i];
|
||||
if (g < -1e-6 || g > 1.0 + 1e-6) {
|
||||
return 2;
|
||||
}
|
||||
if (!isfinite(outputs.chamber_pressure_pa[i])) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* FDM vs FEA peak polished load tolerance (regression gate) */
|
||||
static int test_fdm_fea_peak_tolerance(void) {
|
||||
SolverInputs inputs;
|
||||
@@ -247,6 +267,12 @@ int main(void) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
rc = test_diagnostics_invariants();
|
||||
if (rc != 0) {
|
||||
printf("test_diagnostics_invariants failed: %d\n", rc);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("solver-c tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user