diff --git a/.gitignore b/.gitignore index d32b0f2..8ee86f9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Agents/COMPUTE_PLAN.md b/Agents/COMPUTE_PLAN.md index c33d6e0..3d72abd 100644 --- a/Agents/COMPUTE_PLAN.md +++ b/Agents/COMPUTE_PLAN.md @@ -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). diff --git a/Makefile b/Makefile index 12ab2cb..403ae3f 100644 --- a/Makefile +++ b/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 \ diff --git a/README.md b/README.md index 6f54c03..bbcc70d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -107,7 +107,8 @@ make down ## Validation ```bash -make test # solver-api vitest + gui-ts tests + solver-c test_solver +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 ``` diff --git a/data/cases/base-case.xml b/data/cases/base-case.xml index 6c4f13c..4dad1e1 100644 --- a/data/cases/base-case.xml +++ b/data/cases/base-case.xml @@ -15,7 +15,7 @@ 0 0 - Veren + Majic 0 0 1 - 367.3 (cm) @@ -118,7 +118,7 @@ -1 24 0 - 275.79 + 276 0.8 0 0 @@ -140,8 +140,8 @@ 2.224:1.634:6:1.634:1.634:0:0:0:0:0:0:0:0:0:0:0 0 0 - 1361.3 - 9.989 + 1361 + 10 3 0 2 @@ -152,7 +152,7 @@ 73 1.096 1 - 191/01-27-007-09W2/00 + PLACEHOLDER-WELL 0.1 1 1 diff --git a/data/golden/default.solve.sha256 b/data/golden/default.solve.sha256 index 00d50ae..a0cf08c 100644 --- a/data/golden/default.solve.sha256 +++ b/data/golden/default.solve.sha256 @@ -1 +1 @@ -d433dd1061c9f26679507fac42299d97d6d9c0b446651eeaa6ac03529e424fa0 +5b6a699556725bee5efad5d4d32bd2b8c168a1f0104293014381c00cb2ab508d diff --git a/gui-ts/index.html b/gui-ts/index.html index 272f248..6e0a65d 100644 --- a/gui-ts/index.html +++ b/gui-ts/index.html @@ -3,7 +3,7 @@ - Rod Solver GUI + Majic Rod Solver
diff --git a/gui-ts/src/App.test.tsx b/gui-ts/src/App.test.tsx index 7a29390..43b8de5 100644 --- a/gui-ts/src/App.test.tsx +++ b/gui-ts/src/App.test.tsx @@ -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("191/01-27-007-09W2/00"); + expect(body.xml).toContain("PLACEHOLDER-WELL"); + expect(body.options?.enableProfiles).toBe(true); + expect(body.options?.enableFourierBaseline).toBe(false); }); it("blocks solver run when engineering checks report blocking errors", async () => { diff --git a/gui-ts/src/state/__tests__/engineeringChecks.test.ts b/gui-ts/src/state/__tests__/engineeringChecks.test.ts index f851d35..737194b 100644 --- a/gui-ts/src/state/__tests__/engineeringChecks.test.ts +++ b/gui-ts/src/state/__tests__/engineeringChecks.test.ts @@ -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); }); }); - diff --git a/gui-ts/src/state/__tests__/xmlExport.test.ts b/gui-ts/src/state/__tests__/xmlExport.test.ts index 160f76d..0381b4c 100644 --- a/gui-ts/src/state/__tests__/xmlExport.test.ts +++ b/gui-ts/src/state/__tests__/xmlExport.test.ts @@ -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", () => { diff --git a/gui-ts/src/state/caseModel.ts b/gui-ts/src/state/caseModel.ts index c83096f..f978db1 100644 --- a/gui-ts/src/state/caseModel.ts +++ b/gui-ts/src/state/caseModel.ts @@ -13,14 +13,25 @@ export type RawFieldValue = string | Record | 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", diff --git a/gui-ts/src/state/engineeringChecks.ts b/gui-ts/src/state/engineeringChecks.ts index bfa7d65..0cdc0ce 100644 --- a/gui-ts/src/state/engineeringChecks.ts +++ b/gui-ts/src/state/engineeringChecks.ts @@ -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({ diff --git a/gui-ts/src/state/rodCatalog.ts b/gui-ts/src/state/rodCatalog.ts new file mode 100644 index 0000000..6d65bc7 --- /dev/null +++ b/gui-ts/src/state/rodCatalog.ts @@ -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(); + +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 }; +} diff --git a/gui-ts/src/state/rodJointLength.ts b/gui-ts/src/state/rodJointLength.ts new file mode 100644 index 0000000..a18b048 --- /dev/null +++ b/gui-ts/src/state/rodJointLength.ts @@ -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; + 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, + 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, + rodType: number, + unitsSelection: number +): number { + const Lm = rodJointLengthM(rawFields, rodType); + if (unitsSelection === 1) return Lm; + return Lm / FT_TO_M; +} diff --git a/gui-ts/src/state/tubingGradientEstimate.ts b/gui-ts/src/state/tubingGradientEstimate.ts new file mode 100644 index 0000000..1958c8a --- /dev/null +++ b/gui-ts/src/state/tubingGradientEstimate.ts @@ -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): 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 +): number { + return mixedFluidDensityKgM3(state) * 9.80665; +} + +/** psi/ft for imperial UI / XML native field. */ +export function estimateTubingGradientPsiPerFt( + state: Pick +): 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[0]): number { + return estimateTubingGradientPaM(state); +} diff --git a/gui-ts/src/state/unitsDisplay.ts b/gui-ts/src/state/unitsDisplay.ts new file mode 100644 index 0000000..9077590 --- /dev/null +++ b/gui-ts/src/state/unitsDisplay.ts @@ -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})`; +} diff --git a/gui-ts/src/state/useCaseStore.ts b/gui-ts/src/state/useCaseStore.ts index 1df65f1..ca29abe 100644 --- a/gui-ts/src/state/useCaseStore.ts +++ b/gui-ts/src/state/useCaseStore.ts @@ -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: (key: K, value: CaseState[K]) => void; setSurvey: (rows: SurveyRow[]) => void; addSurveyRow: (row?: Partial) => void; + insertSurveyRowBelow: (index: number, row?: Partial) => void; removeSurveyRow: (index: number) => void; updateSurveyRow: (index: number, patch: Partial) => 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 = {}) => { + 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 = {}) => { - setStateInternal((prev) => ({ - ...prev, - taper: [ - ...prev.taper, - { - diameter: row.diameter ?? 0, - length: row.length ?? 0, - modulus: row.modulus ?? 30.5, - rodType: row.rodType ?? 0 - } - ] - })); + 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 ?? 19.05, + length: row.length ?? 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, diff --git a/gui-ts/src/state/xmlExport.ts b/gui-ts/src/state/xmlExport.ts index e37847d..51fd148 100644 --- a/gui-ts/src/state/xmlExport.ts +++ b/gui-ts/src/state/xmlExport.ts @@ -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 { const m = new Map(); m.set("WellName", state.wellName); @@ -72,6 +86,34 @@ function buildFirstClassMap(state: CaseState): Map { 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)); diff --git a/gui-ts/src/state/xmlImport.ts b/gui-ts/src/state/xmlImport.ts index f1fa7ac..a278e88 100644 --- a/gui-ts/src/state/xmlImport.ts +++ b/gui-ts/src/state/xmlImport.ts @@ -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( diff --git a/gui-ts/src/types.ts b/gui-ts/src/types.ts index b1e203b..ef3d2b6 100644 --- a/gui-ts/src/types.ts +++ b/gui-ts/src/types.ts @@ -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 | 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 | 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; diff --git a/gui-ts/src/ui/App.tsx b/gui-ts/src/ui/App.tsx index 60211ec..342524a 100644 --- a/gui-ts/src/ui/App.tsx +++ b/gui-ts/src/ui/App.tsx @@ -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() { - Rods-Cursor — Case Editor & Solver + Majic Rod Solver
{runSettings.solverModel.toUpperCase()} @@ -223,7 +225,7 @@ export function App() {
{statusMessage} - Well: {store.state.wellName || "—"} + Company: {store.state.company || "—"} Taper sections: {store.state.taper.filter((t) => t.length > 0).length} Survey stations: {store.state.survey.length}
diff --git a/gui-ts/src/ui/common/Wellbore3DView.tsx b/gui-ts/src/ui/common/Wellbore3DView.tsx index 0803a9d..e9a2df4 100644 --- a/gui-ts/src/ui/common/Wellbore3DView.tsx +++ b/gui-ts/src/ui/common/Wellbore3DView.tsx @@ -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

Need at least 2 survey stations to render 3D wellbore.

; } - 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 (
- {overlayMode === "dls" ? ( - <> - Low DLS (< {(DLS_BAD_SECTION_THRESHOLD * 0.5).toFixed(1)}) - Moderate DLS - Bad section DLS (≥ {DLS_BAD_SECTION_THRESHOLD}) - - ) : ( + {overlayMode === "sideLoad" ? ( <> Low side-load risk Moderate side-load risk High side-load risk + ) : ( + Trajectory by MD (hue) )} Rod string gradient
- Max DLS: {maxDls.toFixed(2)} deg/100 - Bad-DLS segments: {highDlsCount} Total MD: {totalLen.toFixed(1)} Pump MD: {caseState.pumpDepth.toFixed(1)}
); } - diff --git a/gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx b/gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx index a780b3a..1be9f44 100644 --- a/gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx +++ b/gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx @@ -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(); diff --git a/gui-ts/src/ui/tabs/FluidTab.tsx b/gui-ts/src/ui/tabs/FluidTab.tsx index 3087ba8..7d8a97d 100644 --- a/gui-ts/src/ui/tabs/FluidTab.tsx +++ b/gui-ts/src/ui/tabs/FluidTab.tsx @@ -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 (
@@ -42,7 +64,11 @@ export function FluidTab({ store }: Props) { update("tubingGradient", v)} /> +
+ + + 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). + +
); diff --git a/gui-ts/src/ui/tabs/PumpTab.tsx b/gui-ts/src/ui/tabs/PumpTab.tsx index d2a8c6f..30cf6cb 100644 --- a/gui-ts/src/ui/tabs/PumpTab.tsx +++ b/gui-ts/src/ui/tabs/PumpTab.tsx @@ -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 (
- update("pumpDiameter", v)} + ariaLabel="Pump plunger diameter" /> diff --git a/gui-ts/src/ui/tabs/ResultsTab.tsx b/gui-ts/src/ui/tabs/ResultsTab.tsx index 514448a..a3bc183 100644 --- a/gui-ts/src/ui/tabs/ResultsTab.tsx +++ b/gui-ts/src/ui/tabs/ResultsTab.tsx @@ -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("dls"); + const [overlayMode, setOverlayMode] = useState("depth"); const [selectedSegment, setSelectedSegment] = useState(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({ )}

- 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.

-
-
- - - Click row or 3D segment to cross-highlight. - -
+
+

+ Click a row or 3D segment to cross-highlight. DLS is shown numerically only (deg per + 100 ft MD). +

@@ -269,45 +252,35 @@ export function ResultsTab({ - - {filteredSegments.map((segment) => { - const severity = - segment.dls >= DLS_BAD_SECTION_THRESHOLD - ? "bad" - : segment.dls >= DLS_BAD_SECTION_THRESHOLD * 0.5 - ? "moderate" - : "low"; - return ( - setSelectedSegment(segment.index)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setSelectedSegment(segment.index); - } - }} - role="button" - tabIndex={0} - aria-label={`Select trajectory segment ${segment.index + 1}`} - style={{ cursor: "pointer" }} - > - - - - - - - - ); - })} - {!filteredSegments.length && ( + {trajectorySegments.map((segment) => ( + setSelectedSegment(segment.index)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedSegment(segment.index); + } + }} + role="button" + tabIndex={0} + aria-label={`Select trajectory segment ${segment.index + 1}`} + style={{ cursor: "pointer" }} + > + + + + + + + ))} + {!trajectorySegments.length && ( - + )} @@ -383,8 +356,122 @@ export function ResultsTab({ ) : (

No card data in response.

)} + {primary?.fourierBaseline && primary.fourierBaseline.card?.length ? ( +
+

+ Fourier baseline ({primary.fourierBaseline.harmonics} harmonics): RMS polished{" "} + {primary.fourierBaseline.residualRmsPolished?.toFixed(1)} N · RMS downhole{" "} + {primary.fourierBaseline.residualRmsDownhole?.toFixed(1)} N +

+ { + 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 } + }} + /> +
+ ) : null} + {primary?.profiles && ( +
+
+ + 0 + ? Math.max( + 0, + ...primary.profiles.sideLoadProfile.map((v) => Math.abs(v)) + ).toFixed(0) + : "—" + } + /> + 0 + ? Math.max( + 0, + ...primary.profiles.frictionProfile.map((v) => Math.abs(v)) + ).toFixed(0) + : "—" + } + /> +
+
+ )} + + {primary?.diagnostics && + primary.diagnostics.chamberPressurePa.length > 0 && + primary.diagnostics.gasFraction.length > 0 && ( +
+
+ + + +
+
+ )} + + {result.fieldTraceability?.fields?.length ? ( +
+

+ Categories mirror docs/engineering/field-traceability.md ( + physics, metadata, parseCalibration, payloadInactive, parsedUnused). +

+
+
MD end ΔMD DLS (deg/100)Severity
{segment.index + 1}{segment.a.md.toFixed(1)}{segment.b.md.toFixed(1)}{segment.dMd.toFixed(1)}{segment.dls.toFixed(2)}{severity}
{segment.index + 1}{segment.a.md.toFixed(1)}{segment.b.md.toFixed(1)}{segment.dMd.toFixed(1)}{segment.dls.toFixed(2)}
No trajectory segments to display.No trajectory segments to display.
+ + + + + + + + + {result.fieldTraceability.fields.map((row) => ( + + + + + + ))} + +
XML fieldCategoryIn file
{row.xmlKey}{row.category}{row.presentInXml ? "yes" : "—"}
+
+
+ ) : null} + {result.comparison && (
diff --git a/gui-ts/src/ui/tabs/RodStringTab.tsx b/gui-ts/src/ui/tabs/RodStringTab.tsx index 4c6e812..c499d89 100644 --- a/gui-ts/src/ui/tabs/RodStringTab.tsx +++ b/gui-ts/src/ui/tabs/RodStringTab.tsx @@ -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) => { + 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 (

- 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{" "} + heuristic — verify for + your vendor tables.

+ Section length: + + @@ -53,8 +129,8 @@ export function RodStringTab({ store }: Props) { Load Base-Case String - {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}
@@ -62,62 +138,140 @@ export function RodStringTab({ store }: Props) { - + - + - - + + + + + + + + - {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 ( + + + + + + + + + + + + + + + ); + })} {state.taper.length === 0 && ( - @@ -125,6 +279,31 @@ export function RodStringTab({ store }: Props) {
## DiameterLength{taperLengthMode === "length" ? `Length (${depthUnit})` : "Joints"} ModulusRod TypeRod typeWt (lb/ft)MTS (lbf)Guide# guidesGuide typeRod guide wt
{i + 1} - updateTaperRow(i, { diameter: v })} - ariaLabel={`Taper ${i + 1} diameter`} - /> - - updateTaperRow(i, { length: v })} - ariaLabel={`Taper ${i + 1} length`} - /> - - updateTaperRow(i, { modulus: v })} - ariaLabel={`Taper ${i + 1} modulus`} - /> - - updateTaperRow(i, { rodType: v })} - /> - - -
{i + 1} + applyDiameterOrType(i, { diameter: v })} + ariaLabel={`Taper ${i + 1} diameter`} + /> + + {taperLengthMode === "length" || isSinker ? ( + updateTaperRow(i, { length: v })} + ariaLabel={`Taper ${i + 1} length`} + /> + ) : ( + updateTaperRow(i, { length: v * jNative })} + ariaLabel={`Taper ${i + 1} joint count`} + /> + )} + + updateTaperRow(i, { modulus: v })} + ariaLabel={`Taper ${i + 1} modulus`} + /> + + applyDiameterOrType(i, { rodType: v })} + ariaLabel={`Taper ${i + 1} rod type`} + /> + + updateTaperRow(i, { weightLbfPerFt: v })} + ariaLabel={`Taper ${i + 1} weight lb per ft`} + /> + + updateTaperRow(i, { mtsLbf: v })} + ariaLabel={`Taper ${i + 1} MTS lbf`} + /> + + + updateTaperRow(i, { + guidesEnabled: checked, + guideCount: checked ? Math.max(1, row.guideCount || 1) : 0 + }) + } + /> + + updateTaperRow(i, { guideCount: Math.max(0, Math.round(v)) })} + ariaLabel={`Taper ${i + 1} guide count`} + /> + + updateTaperRow(i, { guideTypeToken: v })} + ariaLabel={`Taper ${i + 1} guide type`} + /> + + updateTaperRow(i, { rodGuideWeightLbfPerFt: v })} + ariaLabel={`Taper ${i + 1} rod guide weight`} + /> + + +
+ No taper sections. Add rows or load the base-case string.
+ +
+

+ Rod type 2 rows define the string; these fields map to{" "} + SinkerBarDiameter / SinkerBarLength. +

+
+ + update("sinkerBarDiameter", v)} + /> + + + update("sinkerBarLength", v)} + /> + +
+
); } diff --git a/gui-ts/src/ui/tabs/SolverTab.tsx b/gui-ts/src/ui/tabs/SolverTab.tsx index 563e7de..261a5cd 100644 --- a/gui-ts/src/ui/tabs/SolverTab.tsx +++ b/gui-ts/src/ui/tabs/SolverTab.tsx @@ -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({ />
+
+

+ When disabled, the API returns null for profiles /{" "} + diagnostics / Fourier blocks to keep payloads small. Enable when you need + plots or QA detail. +

+
+ + onRunSettingsChange({ + ...runSettings, + outputOptions: { ...runSettings.outputOptions, enableProfiles: checked } + }) + } + /> + + onRunSettingsChange({ + ...runSettings, + outputOptions: { ...runSettings.outputOptions, enableDiagnosticsDetail: checked } + }) + } + /> + + onRunSettingsChange({ + ...runSettings, + outputOptions: { ...runSettings.outputOptions, enableFourierBaseline: checked } + }) + } + /> +
+ + + onRunSettingsChange({ + ...runSettings, + outputOptions: { ...runSettings.outputOptions, fourierHarmonics: Math.round(v) } + }) + } + /> + +
+
diff --git a/gui-ts/src/ui/tabs/TrajectoryTab.tsx b/gui-ts/src/ui/tabs/TrajectoryTab.tsx index 47e50b9..09fc2d4 100644 --- a/gui-ts/src/ui/tabs/TrajectoryTab.tsx +++ b/gui-ts/src/ui/tabs/TrajectoryTab.tsx @@ -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) { MD Inclination (°) Azimuth (°) - + Actions @@ -100,20 +101,30 @@ export function TrajectoryTab({ store }: Props) { /> - +
+ + +
))} {state.survey.length === 0 && ( - + No survey stations. Add rows or load a preset. diff --git a/solver-api/package-lock.json b/solver-api/package-lock.json index 2f9fba6..8a7b093 100644 --- a/solver-api/package-lock.json +++ b/solver-api/package-lock.json @@ -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", diff --git a/solver-api/src/app.js b/solver-api/src/app.js index 04a47d2..6183c05 100644 --- a/solver-api/src/app.js +++ b/solver-api/src/app.js @@ -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, diff --git a/solver-api/src/cardQa.js b/solver-api/src/cardQa.js index a9a6b97..a999df4 100644 --- a/solver-api/src/cardQa.js +++ b/solver-api/src/cardQa.js @@ -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); -} diff --git a/solver-api/src/fieldTraceability.js b/solver-api/src/fieldTraceability.js new file mode 100644 index 0000000..b7d40ff --- /dev/null +++ b/solver-api/src/fieldTraceability.js @@ -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 } | 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] ?? "" + })) + }; +} diff --git a/solver-api/src/schema.js b/solver-api/src/schema.js index 185742b..4f03193 100644 --- a/solver-api/src/schema.js +++ b/solver-api/src/schema.js @@ -20,6 +20,7 @@ export const MVP_FIELDS = [ "TaperModulusArray", "TaperWeightArray", "TaperMTSArray", + "TaperGuidesCountArray", "RodTypeArray", "MoldedGuideFrictionRatio", "WheeledGuideFrictionRatio", diff --git a/solver-api/tests/api.test.js b/solver-api/tests/api.test.js index f471184..cf3e666 100644 --- a/solver-api/tests/api.test.js +++ b/solver-api/tests/api.test.js @@ -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 () => { diff --git a/solver-api/tests/quality.test.js b/solver-api/tests/quality.test.js new file mode 100644 index 0000000..9c97649 --- /dev/null +++ b/solver-api/tests/quality.test.js @@ -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( + "0.2", + "0.28" + ); + 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"); + }); +}); diff --git a/solver-c/src/main.c b/solver-c/src/main.c index af9efc7..51e9e80 100644 --- a/solver-c/src/main.c +++ b/solver-c/src/main.c @@ -4,7 +4,7 @@ #include #include -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,57 +55,65 @@ static void print_json_output(const SolverOutputs *outputs) { printf("]\n"); printf(" },\n"); - printf(" \"profiles\": {\n"); - printf(" \"nodeCount\": %d,\n", outputs->profile_node_count); - printf(" \"trajectory3D\": ["); - for (int i = 0; i < outputs->profile_node_count; i++) { - if (i > 0) printf(", "); - printf("{\"md\": %.6f, \"curvature\": %.9f, \"inclination\": %.9f, \"azimuth\": %.9f}", - outputs->profile_md_m[i], - outputs->profile_curvature_1pm[i], - outputs->profile_inclination_rad[i], - outputs->profile_azimuth_rad[i]); + if (inputs->enable_profiles) { + printf(" \"profiles\": {\n"); + printf(" \"nodeCount\": %d,\n", outputs->profile_node_count); + printf(" \"trajectory3D\": ["); + for (int i = 0; i < outputs->profile_node_count; i++) { + if (i > 0) printf(", "); + printf("{\"md\": %.6f, \"curvature\": %.9f, \"inclination\": %.9f, \"azimuth\": %.9f}", + outputs->profile_md_m[i], + outputs->profile_curvature_1pm[i], + outputs->profile_inclination_rad[i], + outputs->profile_azimuth_rad[i]); + } + printf("],\n"); + printf(" \"sideLoadProfile\": ["); + for (int i = 0; i < outputs->profile_node_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->profile_side_load_n[i]); + } + printf("],\n"); + printf(" \"frictionProfile\": ["); + for (int i = 0; i < outputs->profile_node_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->profile_friction_n[i]); + } + printf("]\n"); + printf(" },\n"); + } else { + printf(" \"profiles\": null,\n"); } - printf("],\n"); - printf(" \"sideLoadProfile\": ["); - for (int i = 0; i < outputs->profile_node_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->profile_side_load_n[i]); - } - printf("],\n"); - printf(" \"frictionProfile\": ["); - for (int i = 0; i < outputs->profile_node_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->profile_friction_n[i]); - } - printf("]\n"); - printf(" },\n"); - printf(" \"diagnostics\": {\n"); - printf(" \"valveStates\": ["); - for (int i = 0; i < outputs->point_count; i++) { - if (i > 0) printf(", "); - printf("{\"travelingOpen\": %s, \"standingOpen\": %s}", - outputs->valve_traveling_open[i] ? "true" : "false", - outputs->valve_standing_open[i] ? "true" : "false"); + if (inputs->enable_diagnostics_detail) { + printf(" \"diagnostics\": {\n"); + printf(" \"valveStates\": ["); + for (int i = 0; i < outputs->point_count; i++) { + if (i > 0) printf(", "); + printf("{\"travelingOpen\": %s, \"standingOpen\": %s}", + outputs->valve_traveling_open[i] ? "true" : "false", + outputs->valve_standing_open[i] ? "true" : "false"); + } + printf("],\n"); + printf(" \"chamberPressurePa\": ["); + for (int i = 0; i < outputs->point_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->chamber_pressure_pa[i]); + } + printf("],\n"); + printf(" \"gasFraction\": ["); + for (int i = 0; i < outputs->point_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->gas_fraction[i]); + } + printf("]\n"); + printf(" },\n"); + } else { + printf(" \"diagnostics\": null,\n"); } - printf("],\n"); - printf(" \"chamberPressurePa\": ["); - for (int i = 0; i < outputs->point_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->chamber_pressure_pa[i]); - } - printf("],\n"); - printf(" \"gasFraction\": ["); - for (int i = 0; i < outputs->point_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->gas_fraction[i]); - } - printf("]\n"); - printf(" },\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; } diff --git a/solver-c/src/main_fea.c b/solver-c/src/main_fea.c index 9d23db7..d8eb4ce 100644 --- a/solver-c/src/main_fea.c +++ b/solver-c/src/main_fea.c @@ -4,7 +4,7 @@ #include #include -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,57 +55,65 @@ static void print_json_output(const SolverOutputs *outputs) { printf("]\n"); printf(" },\n"); - printf(" \"profiles\": {\n"); - printf(" \"nodeCount\": %d,\n", outputs->profile_node_count); - printf(" \"trajectory3D\": ["); - for (int i = 0; i < outputs->profile_node_count; i++) { - if (i > 0) printf(", "); - printf("{\"md\": %.6f, \"curvature\": %.9f, \"inclination\": %.9f, \"azimuth\": %.9f}", - outputs->profile_md_m[i], - outputs->profile_curvature_1pm[i], - outputs->profile_inclination_rad[i], - outputs->profile_azimuth_rad[i]); + if (inputs->enable_profiles) { + printf(" \"profiles\": {\n"); + printf(" \"nodeCount\": %d,\n", outputs->profile_node_count); + printf(" \"trajectory3D\": ["); + for (int i = 0; i < outputs->profile_node_count; i++) { + if (i > 0) printf(", "); + printf("{\"md\": %.6f, \"curvature\": %.9f, \"inclination\": %.9f, \"azimuth\": %.9f}", + outputs->profile_md_m[i], + outputs->profile_curvature_1pm[i], + outputs->profile_inclination_rad[i], + outputs->profile_azimuth_rad[i]); + } + printf("],\n"); + printf(" \"sideLoadProfile\": ["); + for (int i = 0; i < outputs->profile_node_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->profile_side_load_n[i]); + } + printf("],\n"); + printf(" \"frictionProfile\": ["); + for (int i = 0; i < outputs->profile_node_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->profile_friction_n[i]); + } + printf("]\n"); + printf(" },\n"); + } else { + printf(" \"profiles\": null,\n"); } - printf("],\n"); - printf(" \"sideLoadProfile\": ["); - for (int i = 0; i < outputs->profile_node_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->profile_side_load_n[i]); - } - printf("],\n"); - printf(" \"frictionProfile\": ["); - for (int i = 0; i < outputs->profile_node_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->profile_friction_n[i]); - } - printf("]\n"); - printf(" },\n"); - printf(" \"diagnostics\": {\n"); - printf(" \"valveStates\": ["); - for (int i = 0; i < outputs->point_count; i++) { - if (i > 0) printf(", "); - printf("{\"travelingOpen\": %s, \"standingOpen\": %s}", - outputs->valve_traveling_open[i] ? "true" : "false", - outputs->valve_standing_open[i] ? "true" : "false"); + if (inputs->enable_diagnostics_detail) { + printf(" \"diagnostics\": {\n"); + printf(" \"valveStates\": ["); + for (int i = 0; i < outputs->point_count; i++) { + if (i > 0) printf(", "); + printf("{\"travelingOpen\": %s, \"standingOpen\": %s}", + outputs->valve_traveling_open[i] ? "true" : "false", + outputs->valve_standing_open[i] ? "true" : "false"); + } + printf("],\n"); + printf(" \"chamberPressurePa\": ["); + for (int i = 0; i < outputs->point_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->chamber_pressure_pa[i]); + } + printf("],\n"); + printf(" \"gasFraction\": ["); + for (int i = 0; i < outputs->point_count; i++) { + if (i > 0) printf(", "); + printf("%.6f", outputs->gas_fraction[i]); + } + printf("]\n"); + printf(" },\n"); + } else { + printf(" \"diagnostics\": null,\n"); } - printf("],\n"); - printf(" \"chamberPressurePa\": ["); - for (int i = 0; i < outputs->point_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->chamber_pressure_pa[i]); - } - printf("],\n"); - printf(" \"gasFraction\": ["); - for (int i = 0; i < outputs->point_count; i++) { - if (i > 0) printf(", "); - printf("%.6f", outputs->gas_fraction[i]); - } - printf("]\n"); - printf(" },\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; } diff --git a/solver-c/tests/test_solver.c b/solver-c/tests/test_solver.c index b1447fd..a3f2b96 100644 --- a/solver-c/tests/test_solver.c +++ b/solver-c/tests/test_solver.c @@ -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; }