Initial commit: establish deterministic rod-string solver stack.

Set up the C solver core, Node API orchestration, TS GUI workflow, and engineering documentation with cleaned repo hygiene for private Git hosting.

Made-with: Cursor
This commit is contained in:
2026-04-16 21:59:42 -06:00
commit 725a72a773
83 changed files with 14687 additions and 0 deletions

12
gui-ts/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

12
gui-ts/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rod Solver GUI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3213
gui-ts/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
gui-ts/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "gui-ts",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"uplot": "^1.6.32"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.6.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^24.1.1",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vitest": "^2.0.4"
}
}

240
gui-ts/src/App.test.tsx Normal file
View File

@@ -0,0 +1,240 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
const DEFAULT_CASE = {
model: {
wellName: "191/01-27-007-09W2/00",
company: "Veren",
measuredDepth: [0, 100, 200],
inclination: [0, 10, 20],
azimuth: [0, 90, 180],
pumpingSpeed: 5,
pumpDepth: 1727
},
rawFields: {
WellName: "191/01-27-007-09W2/00",
Company: "Veren",
PumpDepth: "1727",
PumpingSpeed: "5",
UnitsSelection: "2",
MeasuredDepthArray: "0:100:200",
InclinationFromVerticalArray: "0:10:20",
AzimuthFromNorthArray: "0:90:180",
TaperDiameterArray: "22.225:19.05",
TaperLengthArray: "800:927",
TaperModulusArray: "30.5:30.5",
RodTypeArray: "3:3",
DesignModeIndex: "0"
},
unsupportedFields: ["DesignModeIndex"],
warnings: []
};
function mockFetchOk<T>(body: T, init: { ok?: boolean } = {}): Response {
return {
ok: init.ok ?? true,
json: async () => body
} as unknown as Response;
}
beforeEach(() => {
vi.restoreAllMocks();
if (typeof window !== "undefined") {
window.history.replaceState(null, "", "/");
}
});
describe("App tabbed shell", () => {
it("hydrates the form from GET /case/default and renders the Well tab by default", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: RequestInfo) => {
if (String(url).endsWith("/case/default")) {
return mockFetchOk(DEFAULT_CASE);
}
throw new Error(`Unexpected fetch: ${String(url)}`);
})
);
render(<App />);
await waitFor(() => {
const wellInput = screen.getByLabelText(/Well Name/i) as HTMLInputElement;
expect(wellInput.value).toBe("191/01-27-007-09W2/00");
});
});
it("switches between tabs", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => mockFetchOk(DEFAULT_CASE))
);
render(<App />);
await waitFor(() => screen.getByLabelText(/Well Name/i));
fireEvent.click(screen.getByRole("tab", { name: /Trajectory/i }));
expect(
screen.getByText(/Well Trajectory — Survey Table/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /Rod String/i }));
expect(screen.getByText(/Rod String Taper Sections/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /Advanced/i }));
expect(screen.getByText(/Raw XML fields/i)).toBeInTheDocument();
});
it("submits synthesized XML to POST /solve when the user runs the solver", async () => {
const solveBody = {
schemaVersion: 2,
units: "SI",
parsed: DEFAULT_CASE,
parseWarnings: [],
solver: {
pointCount: 2,
maxPolishedLoad: 10,
minPolishedLoad: 1,
maxDownholeLoad: 9,
minDownholeLoad: 2,
warnings: [],
card: [
{ position: -1, polishedLoad: 1, downholeLoad: 2 },
{ position: 1, polishedLoad: 10, downholeLoad: 9 }
]
},
runMetadata: {
deterministic: true,
pointCount: 2,
generatedAt: "2026-04-15T00:00:00.000Z",
solverModel: "fdm",
workflow: "predictive"
}
};
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: RequestInfo, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).endsWith("/case/default")) return mockFetchOk(DEFAULT_CASE);
if (String(url).endsWith("/solve")) return mockFetchOk(solveBody);
throw new Error(`Unexpected fetch: ${String(url)}`);
})
);
render(<App />);
await waitFor(() => screen.getByLabelText(/Well Name/i));
fireEvent.click(screen.getByRole("tab", { name: /^Solver$/i }));
fireEvent.click(screen.getByRole("button", { name: /Run Solver/i }));
await waitFor(() =>
expect(calls.some((c) => c.url.endsWith("/solve") && c.init?.method === "POST")).toBe(
true
)
);
const solveCall = calls.find((c) => c.url.endsWith("/solve"));
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>");
});
it("blocks solver run when engineering checks report blocking errors", async () => {
const badCase = {
...DEFAULT_CASE,
rawFields: {
...DEFAULT_CASE.rawFields,
PumpDepth: "3000",
TaperLengthArray: "100:100"
}
};
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: RequestInfo) => {
calls.push(String(url));
if (String(url).endsWith("/case/default")) return mockFetchOk(badCase);
return mockFetchOk({});
})
);
render(<App />);
await waitFor(() => screen.getByLabelText(/Well Name/i));
fireEvent.click(screen.getByRole("tab", { name: /^Solver$/i }));
const runButton = screen.getByRole("button", { name: /Fix checks to run/i });
expect(runButton).toBeDisabled();
expect(calls.some((url) => url.endsWith("/solve"))).toBe(false);
});
it("runs diagnostic workflow with validated surface card payload", async () => {
const solveBody = {
schemaVersion: 2,
units: "SI",
parsed: DEFAULT_CASE,
parseWarnings: [],
solver: {
pointCount: 2,
maxPolishedLoad: 10,
minPolishedLoad: 1,
maxDownholeLoad: 9,
minDownholeLoad: 2,
warnings: [],
card: [
{ position: -1, polishedLoad: 1, downholeLoad: 2 },
{ position: 1, polishedLoad: 10, downholeLoad: 9 }
]
},
runMetadata: {
deterministic: true,
pointCount: 2,
generatedAt: "2026-04-15T00:00:00.000Z",
solverModel: "fdm",
workflow: "diagnostic"
}
};
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: RequestInfo, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).endsWith("/case/default")) return mockFetchOk(DEFAULT_CASE);
if (String(url).endsWith("/solve/validate-card")) {
return mockFetchOk({ ok: true, qa: { ok: true }, schemaVersion: 2 });
}
if (String(url).endsWith("/solve")) return mockFetchOk(solveBody);
throw new Error(`Unexpected fetch: ${String(url)}`);
})
);
render(<App />);
await waitFor(() => screen.getByLabelText(/Well Name/i));
fireEvent.click(screen.getByRole("tab", { name: /Kinematics/i }));
fireEvent.change(screen.getByPlaceholderText(/-1.2,12000/i), {
target: { value: "-1,10000\n0,12000\n1,11000\n2,10500" }
});
fireEvent.click(screen.getByRole("button", { name: /Validate Surface Card/i }));
await waitFor(() =>
expect(calls.some((c) => c.url.endsWith("/solve/validate-card"))).toBe(true)
);
fireEvent.click(screen.getByRole("tab", { name: /^Solver$/i }));
fireEvent.click(screen.getByRole("radio", { name: /Diagnostic/i }));
fireEvent.click(screen.getByRole("button", { name: /Run Solver/i }));
await waitFor(() =>
expect(calls.some((c) => c.url.endsWith("/solve") && c.init?.method === "POST")).toBe(
true
)
);
const solveCall = calls.find((c) => c.url.endsWith("/solve"));
const body = JSON.parse(String(solveCall?.init?.body ?? "{}"));
expect(body.workflow).toBe("diagnostic");
expect(Array.isArray(body.surfaceCard.position)).toBe(true);
expect(Array.isArray(body.surfaceCard.load)).toBe(true);
});
});

1
gui-ts/src/App.tsx Normal file
View File

@@ -0,0 +1 @@
export { App } from "./ui/App";

86
gui-ts/src/api/client.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { ParsedCase, SolveResponse } from "../types";
const API_BASE =
(import.meta as unknown as { env?: { VITE_API_BASE?: string } }).env?.VITE_API_BASE ||
"http://localhost:4400";
export type SolverModel = "fdm" | "fea" | "both";
export type Workflow = "predictive" | "diagnostic";
export type SurfaceCard = {
position: number[];
load: number[];
time?: number[];
};
async function handleJson<T>(resp: Response): Promise<T> {
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
const message =
(body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: null) || `Request failed: HTTP ${resp.status}`;
throw new Error(message);
}
return body as T;
}
export async function fetchDefaultCase(signal?: AbortSignal): Promise<ParsedCase> {
const resp = await fetch(`${API_BASE}/case/default`, { signal });
return handleJson<ParsedCase>(resp);
}
export async function parseCaseXmlApi(xml: string, signal?: AbortSignal): Promise<ParsedCase> {
const resp = await fetch(`${API_BASE}/case/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ xml }),
signal
});
return handleJson<ParsedCase>(resp);
}
export type SolveArgs = {
xml: string;
solverModel: SolverModel;
workflow?: Workflow;
surfaceCard?: SurfaceCard;
options?: {
enableProfiles?: boolean;
enableDiagnosticsDetail?: boolean;
enableFourierBaseline?: boolean;
fourierHarmonics?: number;
};
};
export async function solveCase(args: SolveArgs, signal?: AbortSignal): Promise<SolveResponse> {
const body: Record<string, unknown> = {
xml: args.xml,
solverModel: args.solverModel
};
if (args.workflow) body.workflow = args.workflow;
if (args.surfaceCard) body.surfaceCard = args.surfaceCard;
if (args.options) body.options = args.options;
const resp = await fetch(`${API_BASE}/solve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal
});
return handleJson<SolveResponse>(resp);
}
export async function validateSurfaceCard(
surfaceCard: SurfaceCard,
signal?: AbortSignal
): Promise<{ ok: boolean; qa: Record<string, unknown>; schemaVersion: number }> {
const resp = await fetch(`${API_BASE}/solve/validate-card`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surfaceCard }),
signal
});
return handleJson(resp);
}
export { API_BASE };

10
gui-ts/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { EMPTY_CASE_STATE } from "../caseModel";
import {
DLS_BAD_SECTION_THRESHOLD,
PUMP_ROD_MISMATCH_M,
runEngineeringChecks
} from "../engineeringChecks";
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 }
],
survey: [
{ md: 0, inc: 0, azi: 0 },
{ md: 1000, inc: 0, azi: 0 }
]
};
const checks = runEngineeringChecks(state);
expect(PUMP_ROD_MISMATCH_M).toBe(15);
expect(checks.hasBlockingError).toBe(true);
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(true);
});
it("flags DLS warning above 15 deg/100 threshold", () => {
const state = {
...EMPTY_CASE_STATE,
pumpDepth: 1000,
taper: [
{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }
],
survey: [
{ md: 0, inc: 0, azi: 0 },
{ md: 100, inc: 20, azi: 0 },
{ md: 200, inc: 45, azi: 180 }
]
};
const checks = runEngineeringChecks(state);
expect(DLS_BAD_SECTION_THRESHOLD).toBe(15);
expect(checks.issues.some((i) => i.code === "DLS_HIGH")).toBe(true);
});
});

View File

@@ -0,0 +1,111 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import type { ParsedCase } from "../../types";
import { hydrateFromParsed } from "../xmlImport";
import { serializeCaseXml } from "../xmlExport";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const BASE_CASE_XML = path.resolve(HERE, "../../../../data/cases/base-case.xml");
/**
* Minimal ParsedCase builder that extracts text content of every direct child
* of the <Case> element. Mirrors the subset of `xml2js` behaviour that the
* GUI import path depends on (strings only, no xml2js attr bag here since
* round-trip on first-class fields does not require attribute preservation).
*/
function parseWithDom(xmlText: string): ParsedCase {
const doc = new DOMParser().parseFromString(xmlText, "application/xml");
const parseErr = doc.getElementsByTagName("parsererror");
if (parseErr.length) throw new Error(parseErr[0].textContent ?? "XML parse error");
const caseNode = doc.getElementsByTagName("Case")[0];
if (!caseNode) throw new Error("missing Case element");
const rawFields: Record<string, string> = {};
for (let i = 0; i < caseNode.children.length; i += 1) {
const child = caseNode.children[i];
rawFields[child.tagName] = (child.textContent ?? "").trim();
}
return {
model: {
wellName: rawFields.WellName ?? "",
company: rawFields.Company ?? "",
pumpingSpeed: Number(rawFields.PumpingSpeed ?? 0),
pumpDepth: Number(rawFields.PumpDepth ?? 0),
measuredDepth: [],
inclination: [],
azimuth: []
},
rawFields,
unsupportedFields: []
};
}
describe("xmlExport round-trip", () => {
const xmlText = fs.readFileSync(BASE_CASE_XML, "utf-8");
function raw(parsed: ParsedCase, key: string): string {
const value = parsed.rawFields[key];
return typeof value === "string" ? value : "";
}
it("preserves every first-class field through hydrate -> serialize", () => {
const parsed = parseWithDom(xmlText);
const state = hydrateFromParsed(parsed);
const exportedXml = serializeCaseXml(state);
const reparsed = parseWithDom(exportedXml);
// Well metadata
expect(raw(reparsed, "WellName")).toBe(raw(parsed, "WellName"));
expect(raw(reparsed, "Company")).toBe(raw(parsed, "Company"));
// Numeric first-class fields
expect(Number(raw(reparsed, "PumpDepth"))).toBe(Number(raw(parsed, "PumpDepth")));
expect(Number(raw(reparsed, "PumpingSpeed"))).toBe(Number(raw(parsed, "PumpingSpeed")));
expect(Number(raw(reparsed, "WaterCut"))).toBe(Number(raw(parsed, "WaterCut")));
expect(Number(raw(reparsed, "UnitsSelection"))).toBe(
Number(raw(parsed, "UnitsSelection"))
);
// Arrays preserved (value-wise — original has trailing zeros we may have stripped)
const origMd = raw(parsed, "MeasuredDepthArray").split(":").map(Number);
const reMd = raw(reparsed, "MeasuredDepthArray").split(":").map(Number);
expect(reMd).toEqual(origMd);
const origInc = raw(parsed, "InclinationFromVerticalArray").split(":").map(Number);
const reInc = raw(reparsed, "InclinationFromVerticalArray").split(":").map(Number);
expect(reInc).toEqual(origInc);
const origTaperD = raw(parsed, "TaperDiameterArray").split(":").map(Number);
const reTaperD = raw(reparsed, "TaperDiameterArray").split(":").map(Number);
expect(reTaperD).toEqual(origTaperD);
});
it("preserves unsupported / untouched fields verbatim", () => {
const parsed = parseWithDom(xmlText);
const state = hydrateFromParsed(parsed);
const exportedXml = serializeCaseXml(state);
const reparsed = parseWithDom(exportedXml);
// Representative fields not first-class in the GUI.
expect(raw(reparsed, "Analyst")).toBe(raw(parsed, "Analyst"));
expect(raw(reparsed, "CrankHole")).toBe(raw(parsed, "CrankHole"));
expect(raw(reparsed, "MoldedGuideType")).toBe(raw(parsed, "MoldedGuideType"));
expect(raw(reparsed, "Version")).toBe(raw(parsed, "Version"));
expect(raw(reparsed, "IPRInputMode")).toBe(raw(parsed, "IPRInputMode"));
});
it("reflects user edits in the serialized XML", () => {
const parsed = parseWithDom(xmlText);
const state = hydrateFromParsed(parsed);
const edited = { ...state, wellName: "CHANGED-WELL", pumpingSpeed: 7.5 };
const exportedXml = serializeCaseXml(edited);
const reparsed = parseWithDom(exportedXml);
expect(raw(reparsed, "WellName")).toBe("CHANGED-WELL");
expect(Number(raw(reparsed, "PumpingSpeed"))).toBe(7.5);
});
});

View File

@@ -0,0 +1,179 @@
/**
* Canonical GUI-side case model.
*
* `CaseState` mirrors the first-class fields produced by
* `solver-api/src/xmlParser.js#parseCaseXml` (SI), plus an escape-hatch
* `rawFields` map holding every original XML element keyed by its tag name.
*
* The GUI is the source of truth while editing; `serializeCaseXml` in
* `./xmlExport.ts` converts it back into the XML document shape that
* `POST /solve` expects.
*/
export type RawFieldValue = string | Record<string, unknown> | undefined;
export type TaperRow = {
/** Taper diameter in the XML's native unit (mm in base case). */
diameter: number;
/** Taper length in the XML's native unit (typically metres / feet mix). */
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;
};
export type SurveyRow = {
md: number;
inc: number;
azi: number;
};
export type CaseState = {
// --- Well / metadata ---
wellName: string;
company: string;
// --- Depths / tubing ---
pumpDepth: number;
tubingAnchorLocation: number;
tubingSize: number;
// --- Kinematics / surface BC ---
pumpingSpeed: number;
pumpingSpeedOption: number;
pumpingUnitId: string;
// --- Trajectory ---
survey: SurveyRow[];
// --- Rod string ---
taper: TaperRow[];
// --- Pump ---
pumpDiameter: number;
pumpFriction: number;
pumpIntakePressure: number;
pumpFillageOption: number;
percentPumpFillage: number;
percentUpstrokeTime: number;
percentDownstrokeTime: number;
// --- Fluid ---
waterCut: number;
waterSpecGravity: number;
fluidLevelOilGravity: number;
tubingGradient: number;
// --- Friction / damping ---
rodFrictionCoefficient: number;
stuffingBoxFriction: number;
moldedGuideFrictionRatio: number;
wheeledGuideFrictionRatio: number;
otherGuideFrictionRatio: number;
upStrokeDamping: number;
downStrokeDamping: number;
nonDimensionalFluidDamping: number;
// --- Units ---
unitsSelection: number;
// --- Escape hatch: every raw <Case> child element, preserved verbatim. ---
rawFields: Record<string, RawFieldValue>;
/**
* Insertion order of fields in the originally loaded XML. Preserved so
* exports produce readable diffs against the input file.
*/
rawFieldOrder: string[];
};
/**
* Runtime settings that are not part of the XML case file but that the GUI
* needs to send to the solver API.
*/
export type RunSettings = {
solverModel: "fdm" | "fea" | "both";
workflow: "predictive" | "diagnostic";
};
export const INITIAL_RUN_SETTINGS: RunSettings = {
solverModel: "both",
workflow: "predictive"
};
export const EMPTY_CASE_STATE: CaseState = {
wellName: "",
company: "",
pumpDepth: 0,
tubingAnchorLocation: 0,
tubingSize: 0,
pumpingSpeed: 0,
pumpingSpeedOption: 0,
pumpingUnitId: "",
survey: [],
taper: [],
pumpDiameter: 0,
pumpFriction: 0,
pumpIntakePressure: 0,
pumpFillageOption: 0,
percentPumpFillage: 0,
percentUpstrokeTime: 50,
percentDownstrokeTime: 50,
waterCut: 0,
waterSpecGravity: 1,
fluidLevelOilGravity: 35,
tubingGradient: 0,
rodFrictionCoefficient: 0,
stuffingBoxFriction: 0,
moldedGuideFrictionRatio: 1,
wheeledGuideFrictionRatio: 1,
otherGuideFrictionRatio: 1,
upStrokeDamping: 0,
downStrokeDamping: 0,
nonDimensionalFluidDamping: 0,
unitsSelection: 0,
rawFields: {},
rawFieldOrder: []
};
/** Fields that `serializeCaseXml` writes explicitly (and should therefore not be duplicated from rawFields). */
export const FIRST_CLASS_XML_KEYS = [
"WellName",
"Company",
"PumpDepth",
"TubingAnchorLocation",
"TubingSize",
"PumpingSpeed",
"PumpingSpeedOption",
"PumpingUnitID",
"MeasuredDepthArray",
"InclinationFromVerticalArray",
"AzimuthFromNorthArray",
"TaperDiameterArray",
"TaperLengthArray",
"TaperModulusArray",
"RodTypeArray",
"PumpDiameter",
"PumpFriction",
"PumpIntakePressure",
"PumpFillageOption",
"PercentPumpFillage",
"PercentageUpstrokeTime",
"PercentageDownstrokeTime",
"WaterCut",
"WaterSpecGravity",
"FluidLevelOilGravity",
"TubingGradient",
"RodFrictionCoefficient",
"StuffingBoxFriction",
"MoldedGuideFrictionRatio",
"WheeledGuideFrictionRatio",
"OtherGuideFrictionRatio",
"UpStrokeDampingFactor",
"DownStrokeDampingFactor",
"NonDimensionalFluidDamping",
"UnitsSelection"
] as const;
export type FirstClassXmlKey = (typeof FIRST_CLASS_XML_KEYS)[number];

View File

@@ -0,0 +1,83 @@
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";
code: string;
message: string;
};
export type EngineeringChecks = {
issues: EngineeringIssue[];
hasBlockingError: boolean;
};
export function runEngineeringChecks(state: CaseState): EngineeringChecks {
const issues: EngineeringIssue[] = [];
const activeTaper = state.taper.filter((t) => Number.isFinite(t.length) && t.length > 0);
const rodTotal = activeTaper.reduce((acc, t) => acc + t.length, 0);
const pumpDepth = state.pumpDepth;
if (rodTotal > 0 && pumpDepth > 0) {
const diff = Math.abs(pumpDepth - rodTotal);
if (diff > PUMP_ROD_MISMATCH_M) {
issues.push({
severity: "error",
code: "PUMP_ROD_MISMATCH_15M",
message: `Pump depth (${pumpDepth.toFixed(1)}) and total rod length (${rodTotal.toFixed(
1
)}) differ by ${diff.toFixed(1)} m (> ${PUMP_ROD_MISMATCH_M} m limit).`
});
}
}
if (state.survey.length < 2) {
issues.push({
severity: "error",
code: "SURVEY_TOO_SHORT",
message: "Trajectory needs at least 2 survey stations."
});
} 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({
severity: "error",
code: "SURVEY_MD_NON_MONOTONIC",
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({
severity: "warning",
code: "SURVEY_BELOW_PUMP_MISSING",
message: `Trajectory ends at MD ${maxMd.toFixed(
1
)}, shallower than pump depth ${pumpDepth.toFixed(1)}.`
});
}
}
return {
issues,
hasBlockingError: issues.some((issue) => issue.severity === "error")
};
}

View File

@@ -0,0 +1,73 @@
import type { SurveyRow } from "./caseModel";
export type TrajectoryPoint3D = { x: number; y: number; z: number; md: number };
export type TrajectorySegment = {
index: number;
a: TrajectoryPoint3D;
b: TrajectoryPoint3D;
dMd: number;
dls: number;
};
export function computeDoglegSeverityDegPer100(rowA: SurveyRow, rowB: SurveyRow): number {
const dMd = rowB.md - rowA.md;
if (!Number.isFinite(dMd) || dMd <= 1e-6) return 0;
const inc1 = (rowA.inc * Math.PI) / 180;
const inc2 = (rowB.inc * Math.PI) / 180;
const azi1 = (rowA.azi * Math.PI) / 180;
const azi2 = (rowB.azi * Math.PI) / 180;
const cosDogleg =
Math.cos(inc1) * Math.cos(inc2) + Math.sin(inc1) * Math.sin(inc2) * Math.cos(azi2 - azi1);
const clamped = Math.min(1, Math.max(-1, cosDogleg));
const doglegDeg = (Math.acos(clamped) * 180) / Math.PI;
return (doglegDeg / dMd) * 100;
}
export function buildTrajectorySegments(survey: SurveyRow[]): TrajectorySegment[] {
if (survey.length < 2) return [];
const points: TrajectoryPoint3D[] = [{ x: 0, y: 0, z: 0, md: survey[0].md }];
for (let i = 1; i < survey.length; i += 1) {
const prev = survey[i - 1];
const curr = survey[i];
const dMd = Math.max(curr.md - prev.md, 0);
const incRad = (curr.inc * Math.PI) / 180;
const azRad = (curr.azi * Math.PI) / 180;
const dx = dMd * Math.sin(incRad) * Math.sin(azRad);
const dy = dMd * Math.sin(incRad) * Math.cos(azRad);
const dz = dMd * Math.cos(incRad);
const last = points[points.length - 1];
points.push({ x: last.x + dx, y: last.y + dy, z: last.z + dz, md: curr.md });
}
const segments: TrajectorySegment[] = [];
for (let i = 1; i < points.length; i += 1) {
segments.push({
index: i - 1,
a: points[i - 1],
b: points[i],
dMd: Math.max(points[i].md - points[i - 1].md, 0),
dls: computeDoglegSeverityDegPer100(survey[i - 1], survey[i])
});
}
return segments;
}
export function interpolateAlongMd(
segments: TrajectorySegment[],
mdTarget: number
): TrajectoryPoint3D | null {
if (!segments.length) return null;
for (const segment of segments) {
if (mdTarget >= segment.a.md && mdTarget <= segment.b.md) {
const span = Math.max(segment.b.md - segment.a.md, 1e-9);
const t = (mdTarget - segment.a.md) / span;
return {
x: segment.a.x + (segment.b.x - segment.a.x) * t,
y: segment.a.y + (segment.b.y - segment.a.y) * t,
z: segment.a.z + (segment.b.z - segment.a.z) * t,
md: mdTarget
};
}
}
return { ...segments[segments.length - 1].b };
}

View File

@@ -0,0 +1,130 @@
import { useCallback, useMemo, useState } from "react";
import type { CaseState, SurveyRow, TaperRow } from "./caseModel";
import { EMPTY_CASE_STATE } from "./caseModel";
export type CaseStore = {
state: CaseState;
setState: (next: CaseState) => void;
update: <K extends keyof CaseState>(key: K, value: CaseState[K]) => void;
setSurvey: (rows: SurveyRow[]) => void;
addSurveyRow: (row?: Partial<SurveyRow>) => void;
removeSurveyRow: (index: number) => void;
updateSurveyRow: (index: number, patch: Partial<SurveyRow>) => void;
setTaper: (rows: TaperRow[]) => void;
addTaperRow: (row?: Partial<TaperRow>) => void;
removeTaperRow: (index: number) => void;
updateTaperRow: (index: number, patch: Partial<TaperRow>) => void;
setRawField: (key: string, value: string) => void;
};
export function useCaseStore(initial: CaseState = EMPTY_CASE_STATE): CaseStore {
const [state, setStateInternal] = useState<CaseState>(initial);
const setState = useCallback((next: CaseState) => setStateInternal(next), []);
const update = useCallback(
<K extends keyof CaseState>(key: K, value: CaseState[K]) => {
setStateInternal((prev) => ({ ...prev, [key]: value }));
},
[]
);
const setSurvey = useCallback((rows: SurveyRow[]) => {
setStateInternal((prev) => ({ ...prev, survey: rows }));
}, []);
const addSurveyRow = useCallback((row: Partial<SurveyRow> = {}) => {
setStateInternal((prev) => ({
...prev,
survey: [...prev.survey, { md: row.md ?? 0, inc: row.inc ?? 0, azi: row.azi ?? 0 }]
}));
}, []);
const removeSurveyRow = useCallback((index: number) => {
setStateInternal((prev) => ({
...prev,
survey: prev.survey.filter((_, i) => i !== index)
}));
}, []);
const updateSurveyRow = useCallback((index: number, patch: Partial<SurveyRow>) => {
setStateInternal((prev) => ({
...prev,
survey: prev.survey.map((row, i) => (i === index ? { ...row, ...patch } : row))
}));
}, []);
const setTaper = useCallback((rows: TaperRow[]) => {
setStateInternal((prev) => ({ ...prev, taper: rows }));
}, []);
const addTaperRow = useCallback((row: Partial<TaperRow> = {}) => {
setStateInternal((prev) => ({
...prev,
taper: [
...prev.taper,
{
diameter: row.diameter ?? 0,
length: row.length ?? 0,
modulus: row.modulus ?? 30.5,
rodType: row.rodType ?? 0
}
]
}));
}, []);
const removeTaperRow = useCallback((index: number) => {
setStateInternal((prev) => ({
...prev,
taper: prev.taper.filter((_, i) => i !== index)
}));
}, []);
const updateTaperRow = useCallback((index: number, patch: Partial<TaperRow>) => {
setStateInternal((prev) => ({
...prev,
taper: prev.taper.map((row, i) => (i === index ? { ...row, ...patch } : row))
}));
}, []);
const setRawField = useCallback((key: string, value: string) => {
setStateInternal((prev) => {
const nextRaw = { ...prev.rawFields, [key]: value };
const order = prev.rawFieldOrder.includes(key)
? prev.rawFieldOrder
: [...prev.rawFieldOrder, key];
return { ...prev, rawFields: nextRaw, rawFieldOrder: order };
});
}, []);
return useMemo(
() => ({
state,
setState,
update,
setSurvey,
addSurveyRow,
removeSurveyRow,
updateSurveyRow,
setTaper,
addTaperRow,
removeTaperRow,
updateTaperRow,
setRawField
}),
[
state,
setState,
update,
setSurvey,
addSurveyRow,
removeSurveyRow,
updateSurveyRow,
setTaper,
addTaperRow,
removeTaperRow,
updateTaperRow,
setRawField
]
);
}

View File

@@ -0,0 +1,160 @@
import type { CaseState, RawFieldValue } from "./caseModel";
import { FIRST_CLASS_XML_KEYS } from "./caseModel";
/**
* Serialize a CaseState back into the XML document shape expected by
* `POST /solve`. Preserves original field ordering when available and keeps
* untouched `rawFields` verbatim.
*/
export function serializeCaseXml(state: CaseState): string {
const firstClassSet = new Set<string>(FIRST_CLASS_XML_KEYS);
const firstClassValues = buildFirstClassMap(state);
// Preserve original order, then append any newly-added fields.
const order: string[] = [];
const seen = new Set<string>();
for (const key of state.rawFieldOrder) {
if (!seen.has(key)) {
order.push(key);
seen.add(key);
}
}
for (const key of FIRST_CLASS_XML_KEYS) {
if (!seen.has(key)) {
order.push(key);
seen.add(key);
}
}
for (const key of Object.keys(state.rawFields)) {
if (!seen.has(key)) {
order.push(key);
seen.add(key);
}
}
const lines: string[] = [];
lines.push('<?xml version="1.0"?>');
lines.push("<INPRoot>");
lines.push(" <Case>");
for (const key of order) {
const firstClass = firstClassValues.get(key);
if (firstClass !== undefined) {
lines.push(` ${renderElement(key, firstClass, null)}`);
} else if (firstClassSet.has(key)) {
// First-class key but no explicit value mapped — skip.
continue;
} else {
const raw = state.rawFields[key];
lines.push(` ${renderElement(key, textOf(raw), attrsOf(raw))}`);
}
}
lines.push(" </Case>");
lines.push("</INPRoot>");
return lines.join("\n") + "\n";
}
function buildFirstClassMap(state: CaseState): Map<string, string> {
const m = new Map<string, string>();
m.set("WellName", state.wellName);
m.set("Company", state.company);
m.set("PumpDepth", formatNumber(state.pumpDepth));
m.set("TubingAnchorLocation", formatNumber(state.tubingAnchorLocation));
m.set("TubingSize", formatNumber(state.tubingSize));
m.set("PumpingSpeed", formatNumber(state.pumpingSpeed));
m.set("PumpingSpeedOption", formatNumber(state.pumpingSpeedOption));
m.set("PumpingUnitID", state.pumpingUnitId);
m.set("MeasuredDepthArray", serializeColonArray(state.survey.map((r) => r.md)));
m.set("InclinationFromVerticalArray", serializeColonArray(state.survey.map((r) => r.inc)));
m.set("AzimuthFromNorthArray", serializeColonArray(state.survey.map((r) => r.azi)));
m.set("TaperDiameterArray", serializeColonArray(state.taper.map((r) => r.diameter)));
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("PumpDiameter", formatNumber(state.pumpDiameter));
m.set("PumpFriction", formatNumber(state.pumpFriction));
m.set("PumpIntakePressure", formatNumber(state.pumpIntakePressure));
m.set("PumpFillageOption", formatNumber(state.pumpFillageOption));
m.set("PercentPumpFillage", formatNumber(state.percentPumpFillage));
m.set("PercentageUpstrokeTime", formatNumber(state.percentUpstrokeTime));
m.set("PercentageDownstrokeTime", formatNumber(state.percentDownstrokeTime));
m.set("WaterCut", formatNumber(state.waterCut));
m.set("WaterSpecGravity", formatNumber(state.waterSpecGravity));
m.set("FluidLevelOilGravity", formatNumber(state.fluidLevelOilGravity));
m.set("TubingGradient", formatNumber(state.tubingGradient));
m.set("RodFrictionCoefficient", formatNumber(state.rodFrictionCoefficient));
m.set("StuffingBoxFriction", formatNumber(state.stuffingBoxFriction));
m.set("MoldedGuideFrictionRatio", formatNumber(state.moldedGuideFrictionRatio));
m.set("WheeledGuideFrictionRatio", formatNumber(state.wheeledGuideFrictionRatio));
m.set("OtherGuideFrictionRatio", formatNumber(state.otherGuideFrictionRatio));
m.set("UpStrokeDampingFactor", formatNumber(state.upStrokeDamping));
m.set("DownStrokeDampingFactor", formatNumber(state.downStrokeDamping));
m.set("NonDimensionalFluidDamping", formatNumber(state.nonDimensionalFluidDamping));
m.set("UnitsSelection", formatNumber(state.unitsSelection));
return m;
}
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 attrsOf(value: RawFieldValue): Record<string, string> | null {
if (value && typeof value === "object") {
const attrs = (value as Record<string, unknown>).$;
if (attrs && typeof attrs === "object") {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
out[k] = String(v);
}
return out;
}
}
return null;
}
function renderElement(
tag: string,
text: string,
attrs: Record<string, string> | null
): string {
const attrStr = attrs
? Object.entries(attrs)
.map(([k, v]) => ` ${k}="${escapeXml(v)}"`)
.join("")
: "";
if (!text) {
return `<${tag}${attrStr} />`;
}
return `<${tag}${attrStr}>${escapeXml(text)}</${tag}>`;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function formatNumber(value: number): string {
if (!Number.isFinite(value)) return "0";
if (Number.isInteger(value)) return String(value);
// Trim trailing zeros while keeping up to 6 decimals to match base-case style.
const fixed = value.toFixed(6);
return fixed.replace(/\.?0+$/, "");
}
function serializeColonArray(values: number[]): string {
if (values.length === 0) return "0";
return values.map((v) => formatNumber(v)).join(":");
}

View File

@@ -0,0 +1,145 @@
import type { ParsedCase } from "../types";
import {
EMPTY_CASE_STATE,
type CaseState,
type RawFieldValue,
type SurveyRow,
type TaperRow
} from "./caseModel";
/** Flatten xml2js node to its text content (preserves attr bag in '$' if present). */
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._;
if (Object.keys(obj).length === 1 && "$" in obj) return "";
}
return "";
}
function numberOf(value: RawFieldValue, fallback = 0): number {
const text = textOf(value).trim();
if (!text) return fallback;
const n = Number(text);
return Number.isFinite(n) ? n : fallback;
}
function stringOf(value: RawFieldValue, fallback = ""): string {
const text = textOf(value).trim();
return text || fallback;
}
function parseColonArray(value: RawFieldValue): number[] {
const text = textOf(value);
if (!text) return [];
return text
.split(":")
.map((piece) => piece.trim())
.filter((piece) => piece.length > 0)
.map((piece) => Number(piece))
.filter((n) => Number.isFinite(n));
}
/**
* Hydrate a CaseState from the `parsed` block returned by the solver-api.
* Values come primarily from `rawFields` (pre-normalization) so the GUI
* edits the XML-native units directly. `parsed.model` is used only as a
* fallback when `rawFields` lacks an entry.
*/
export function hydrateFromParsed(parsed: ParsedCase): CaseState {
const raw = (parsed.rawFields ?? {}) as Record<string, RawFieldValue>;
const model = parsed.model ?? ({} as ParsedCase["model"]);
const rawFieldOrder = Object.keys(raw);
const md = parseColonArray(raw.MeasuredDepthArray);
const inc = parseColonArray(raw.InclinationFromVerticalArray);
const azi = parseColonArray(raw.AzimuthFromNorthArray);
const surveyLen = Math.max(md.length, inc.length, azi.length);
const survey: SurveyRow[] = [];
for (let i = 0; i < surveyLen; i += 1) {
survey.push({
md: md[i] ?? 0,
inc: inc[i] ?? 0,
azi: azi[i] ?? 0
});
}
const diam = parseColonArray(raw.TaperDiameterArray);
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 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.
taper.push({
diameter: diam[i] ?? 0,
length: length[i] ?? 0,
modulus: modulus[i] ?? 0,
rodType: rodType[i] ?? 0
});
}
return {
...EMPTY_CASE_STATE,
wellName: stringOf(raw.WellName, model.wellName ?? ""),
company: stringOf(raw.Company, model.company ?? ""),
pumpDepth: numberOf(raw.PumpDepth, model.pumpDepth ?? 0),
tubingAnchorLocation: numberOf(raw.TubingAnchorLocation, model.tubingAnchorLocation ?? 0),
tubingSize: numberOf(raw.TubingSize, model.tubingSize ?? 0),
pumpingSpeed: numberOf(raw.PumpingSpeed, model.pumpingSpeed ?? 0),
pumpingSpeedOption: numberOf(raw.PumpingSpeedOption, model.pumpingSpeedOption ?? 0),
pumpingUnitId: stringOf(raw.PumpingUnitID, model.pumpingUnitId ?? ""),
survey,
taper,
pumpDiameter: numberOf(raw.PumpDiameter, model.pumpDiameter ?? 0),
pumpFriction: numberOf(raw.PumpFriction, model.pumpFriction ?? 0),
pumpIntakePressure: numberOf(raw.PumpIntakePressure, model.pumpIntakePressure ?? 0),
pumpFillageOption: numberOf(raw.PumpFillageOption, model.pumpFillageOption ?? 0),
percentPumpFillage: numberOf(raw.PercentPumpFillage, model.percentPumpFillage ?? 0),
percentUpstrokeTime: numberOf(raw.PercentageUpstrokeTime, model.percentUpstrokeTime ?? 50),
percentDownstrokeTime: numberOf(raw.PercentageDownstrokeTime, model.percentDownstrokeTime ?? 50),
waterCut: numberOf(raw.WaterCut, model.waterCut ?? 0),
waterSpecGravity: numberOf(raw.WaterSpecGravity, model.waterSpecGravity ?? 1),
fluidLevelOilGravity: numberOf(raw.FluidLevelOilGravity, model.fluidLevelOilGravity ?? 35),
tubingGradient: numberOf(raw.TubingGradient, model.tubingGradient ?? 0),
rodFrictionCoefficient: numberOf(
raw.RodFrictionCoefficient,
model.rodFrictionCoefficient ?? 0
),
stuffingBoxFriction: numberOf(raw.StuffingBoxFriction, model.stuffingBoxFriction ?? 0),
moldedGuideFrictionRatio: numberOf(
raw.MoldedGuideFrictionRatio,
model.moldedGuideFrictionRatio ?? 1
),
wheeledGuideFrictionRatio: numberOf(
raw.WheeledGuideFrictionRatio,
model.wheeledGuideFrictionRatio ?? 1
),
otherGuideFrictionRatio: numberOf(
raw.OtherGuideFrictionRatio,
model.otherGuideFrictionRatio ?? 1
),
upStrokeDamping: numberOf(raw.UpStrokeDampingFactor, model.upStrokeDamping ?? 0),
downStrokeDamping: numberOf(raw.DownStrokeDampingFactor, model.downStrokeDamping ?? 0),
nonDimensionalFluidDamping: numberOf(
raw.NonDimensionalFluidDamping,
model.nonDimensionalFluidDamping ?? 0
),
unitsSelection: numberOf(raw.UnitsSelection, model.unitsSelection ?? 0),
rawFields: { ...raw },
rawFieldOrder
};
}

383
gui-ts/src/styles.css Normal file
View File

@@ -0,0 +1,383 @@
:root {
--bg: #0b1220;
--panel: #111827;
--panel-2: #0f172a;
--panel-3: #1e293b;
--border: #334155;
--border-strong: #475569;
--text: #e5e7eb;
--text-dim: #94a3b8;
--text-muted: #64748b;
--accent: #38bdf8;
--accent-2: #f59e0b;
--danger: #ef4444;
--ok: #4ade80;
}
* { box-sizing: border-box; }
html, body, #root {
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, "Segoe UI", system-ui, Arial, sans-serif;
font-size: 13px;
line-height: 1.4;
}
.app-shell {
max-width: 1180px;
margin: 0 auto;
padding: 12px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: linear-gradient(90deg, #0f172a, #111827);
border: 1px solid var(--border);
border-radius: 6px 6px 0 0;
font-size: 14px;
}
.app-title { font-weight: 600; letter-spacing: 0.02em; }
.app-logo { margin-right: 8px; color: var(--accent); }
.app-header-meta { display: flex; gap: 6px; }
.pill {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid var(--border-strong);
border-radius: 999px;
color: var(--text-dim);
background: var(--panel-2);
}
/* Tab strip */
.tab-strip {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 0;
margin: 0;
background: var(--panel-2);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.tab {
padding: 8px 14px;
background: transparent;
color: var(--text-dim);
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.01em;
}
.tab:hover { color: var(--text); background: var(--panel-3); }
.tab-active {
color: var(--text);
background: var(--panel);
border-bottom: 2px solid var(--accent);
}
/* Tab body */
.tab-body {
flex: 1;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
padding: 14px;
min-height: 520px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Fieldset */
.panel-fieldset {
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 14px;
background: var(--panel-2);
margin: 0;
}
.panel-fieldset legend {
padding: 0 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.panel-note {
font-size: 12px;
color: var(--text-dim);
margin: 4px 0 8px 0;
}
.panel-note code { background: var(--panel-3); padding: 1px 4px; border-radius: 3px; }
/* Label+input row */
.panel-row {
display: grid;
grid-template-columns: 200px 1fr;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.panel-row label { font-size: 12px; color: var(--text-dim); }
.panel-row-input { display: flex; flex-direction: column; gap: 3px; }
.panel-row-hint { font-size: 11px; color: var(--text-muted); }
.tab-grid { display: grid; gap: 12px; }
.tab-grid.two { grid-template-columns: 1fr 1fr; }
.tab-grid.three { grid-template-columns: 1fr 1fr 1fr; }
@media (max-width: 880px) {
.tab-grid.two, .tab-grid.three { grid-template-columns: 1fr; }
.panel-row { grid-template-columns: 140px 1fr; }
}
/* Inputs */
.panel-input {
width: 100%;
padding: 6px 8px;
background: var(--panel-3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
font-size: 12px;
}
.panel-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(56,189,248,0.15);
}
.panel-input:disabled { opacity: 0.5; cursor: not-allowed; }
.panel-checkbox { display: inline-flex; align-items: center; gap: 6px; }
.panel-radio-group { display: flex; flex-wrap: wrap; gap: 12px; }
.panel-radio { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
/* Buttons */
.btn {
padding: 6px 12px;
background: var(--panel-3);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: 4px;
font: inherit;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, border-color 0.12s ease;
}
.btn:hover { background: #243448; border-color: var(--accent); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #0ea5e9; border-color: #0284c7; color: #001018; font-weight: 600; }
.btn-primary:hover { background: #38bdf8; border-color: #0ea5e9; }
.btn-danger { color: #fecaca; border-color: #7f1d1d; background: transparent; padding: 2px 8px; font-size: 11px; }
.btn-danger:hover { background: #7f1d1d; color: #fff; border-color: #991b1b; }
.btn-secondary { background: var(--panel-3); }
.button-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin: 4px 0 8px 0;
}
.action-row { display: flex; justify-content: flex-end; gap: 8px; padding-top: 10px; }
/* Tables */
.table-scroll {
max-height: 380px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--panel-3);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table thead th {
position: sticky;
top: 0;
background: var(--panel);
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
font-weight: 600;
color: var(--text-dim);
z-index: 1;
}
.data-table tbody td {
padding: 3px 6px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.data-table tbody tr.row-selected td {
background: rgba(56, 189, 248, 0.16);
}
.data-table tbody tr[role="button"]:focus-visible td {
outline: 1px solid #38bdf8;
outline-offset: -1px;
background: rgba(56, 189, 248, 0.1);
}
.data-table tbody td .panel-input { padding: 3px 6px; font-size: 11px; }
.data-table .empty-row { text-align: center; color: var(--text-muted); padding: 18px; }
/* KPI grid */
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.kpi-cell {
background: var(--panel-3);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 10px;
}
.kpi-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 3px;
}
.kpi-val { font-size: 14px; font-weight: 600; color: var(--text); }
@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
/* Callouts */
.callout {
padding: 10px 12px;
border-radius: 4px;
background: var(--panel-3);
border-left: 3px solid var(--border-strong);
font-size: 12px;
}
.callout-info { border-left-color: var(--accent); }
.callout-error { border-left-color: var(--danger); color: #fca5a5; }
.callout-warning { border-left-color: var(--accent-2); color: #fcd34d; }
.warning-list { margin: 0; padding-left: 16px; color: #fcd34d; font-size: 12px; }
.warning-list li { margin-bottom: 3px; }
.mono-block {
font-family: "JetBrains Mono", "Fira Code", "Courier New", monospace;
font-size: 11px;
background: var(--panel-3);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
}
.advanced-textarea {
width: 100%;
margin-top: 8px;
padding: 8px;
font-family: "JetBrains Mono", "Fira Code", "Courier New", monospace;
font-size: 11px;
background: var(--panel-3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
resize: vertical;
}
/* Status bar */
.app-statusbar {
display: flex;
gap: 16px;
padding: 6px 12px;
margin-top: 8px;
font-size: 11px;
color: var(--text-dim);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 4px;
}
/* Spinner */
.spinner {
display: inline-block;
width: 10px;
height: 10px;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
border-radius: 50%;
margin-right: 6px;
vertical-align: -2px;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* uPlot palette overrides for dark theme */
.uplot-host .u-legend { color: var(--text-dim); font-size: 11px; }
.uplot-host .u-wrap { background: var(--panel-3); }
/* 3D wellbore */
.wellbore-3d-wrap {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-3);
padding: 8px;
}
.wellbore-3d {
width: 100%;
height: auto;
display: block;
border: 1px solid var(--border);
border-radius: 4px;
background: #020617;
}
.wellbore-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
font-size: 11px;
color: var(--text-dim);
}
.wellbore-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.wellbore-legend i {
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.wellbore-kpis {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 6px;
font-size: 11px;
color: var(--text-dim);
}

48
gui-ts/src/testSetup.ts Normal file
View File

@@ -0,0 +1,48 @@
import "@testing-library/jest-dom";
/**
* uPlot touches `window.matchMedia` at module-load time for HiDPI handling;
* jsdom doesn't provide it, so stub a minimal matcher before the import
* graph resolves.
*/
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
} as unknown as MediaQueryList);
}
if (typeof window !== "undefined" && typeof window.ResizeObserver === "undefined") {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(window as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
ResizeObserverStub;
}
if (typeof window !== "undefined" && typeof HTMLCanvasElement !== "undefined") {
const proto = HTMLCanvasElement.prototype as HTMLCanvasElement["prototype"] & {
getContext?: (contextId: string, options?: unknown) => unknown;
};
if (typeof proto.getContext !== "function") {
proto.getContext = () => null;
} else {
const original = proto.getContext;
proto.getContext = function (contextId: string, options?: unknown) {
try {
return original.call(this, contextId, options);
} catch {
return null;
}
};
}
}

145
gui-ts/src/types.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* Types mirroring the solver-api JSON responses. These are advisory — the
* server remains the source of truth (`schemaVersion: 2`).
*/
export type ParsedModel = {
wellName: string;
company: string;
measuredDepth: number[];
inclination: number[];
azimuth: number[];
pumpingSpeed: number;
pumpDepth: number;
tubingAnchorLocation?: number;
rodFrictionCoefficient?: number;
stuffingBoxFriction?: number;
pumpFriction?: number;
waterCut?: number;
waterSpecGravity?: number;
fluidLevelOilGravity?: number;
taperDiameter?: number[];
taperLength?: number[];
taperModulus?: number[];
rodType?: number[];
tubingSize?: number;
unitsSelection?: number;
upStrokeDamping?: number;
downStrokeDamping?: number;
nonDimensionalFluidDamping?: number;
moldedGuideFrictionRatio?: number;
wheeledGuideFrictionRatio?: number;
otherGuideFrictionRatio?: number;
pumpDiameter?: number;
pumpIntakePressure?: number;
tubingGradient?: number;
pumpFillageOption?: number;
percentPumpFillage?: number;
percentUpstrokeTime?: number;
percentDownstrokeTime?: number;
pumpingUnitId?: string;
pumpingSpeedOption?: number;
};
export type ParsedCase = {
model: ParsedModel;
unsupportedFields: string[];
rawFields: Record<string, string | Record<string, unknown> | undefined>;
warnings?: string[];
};
export type CardPoint = {
position: number;
polishedLoad: number;
downholeLoad: number;
polishedStressPa?: number;
sideLoadN?: number;
};
export type SolverOutput = {
pointCount: number;
maxPolishedLoad: number;
minPolishedLoad: number;
maxDownholeLoad: number;
minDownholeLoad: number;
gasInterference?: boolean;
maxCfl?: number;
waveSpeedRefMPerS?: number;
warnings: string[];
card: CardPoint[];
pumpMovement?: {
stroke: number;
position: number[];
velocity: number[];
};
profiles?: {
nodeCount: number;
trajectory3D: Array<{ md: number; curvature: number; inclination: number; azimuth: number }>;
sideLoadProfile: number[];
frictionProfile: number[];
};
diagnostics?: {
valveStates: Array<{ travelingOpen: boolean; standingOpen: boolean }>;
chamberPressurePa: number[];
gasFraction: number[];
};
fourierBaseline?: null | {
harmonics: number;
residualRmsPolished: number;
residualRmsDownhole: number;
card: Array<{ position: number; polishedLoad: number; downholeLoad: number }>;
};
};
export type SolveResponse = {
schemaVersion?: number;
units?: string;
parseWarnings?: string[];
surfaceCardQa?: Record<string, unknown> | null;
fingerprint?: string;
parsed: ParsedCase;
solver: SolverOutput;
pumpMovement?: SolverOutput["pumpMovement"] | null;
solvers?: {
fdm: SolverOutput;
fea: SolverOutput;
};
comparison?: {
schemaVersion?: number;
peakLoadDeltas?: {
polishedMaxDelta: number;
polishedMinDelta: number;
downholeMaxDelta: number;
downholeMinDelta: number;
};
polishedMaxDelta: number;
polishedMinDelta: number;
downholeMaxDelta: number;
downholeMinDelta: number;
residualSummary?: { points: number; rms: number };
pointwiseResiduals?: {
points: number;
series: Array<{
position: number;
polishedLoadResidual: number;
downholeLoadResidual: number;
}>;
};
fourier?: null | {
baselineName?: string;
points?: number;
residualRms?: number;
};
};
verbose?: Record<string, unknown>;
runMetadata: {
deterministic: boolean;
pointCount: number;
generatedAt: string;
source?: string;
solverModel?: "fdm" | "fea" | "both";
workflow?: string;
schemaVersion?: number;
units?: string;
};
};

262
gui-ts/src/ui/App.tsx Normal file
View File

@@ -0,0 +1,262 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Tabs, type TabDef } from "./Tabs";
import { useCaseStore } from "../state/useCaseStore";
import {
EMPTY_CASE_STATE,
type RunSettings,
INITIAL_RUN_SETTINGS
} from "../state/caseModel";
import { hydrateFromParsed } from "../state/xmlImport";
import { serializeCaseXml } from "../state/xmlExport";
import { fetchDefaultCase, solveCase, validateSurfaceCard, type SurfaceCard } from "../api/client";
import type { SolveResponse } from "../types";
import { WellTab } from "./tabs/WellTab";
import { TrajectoryTab } from "./tabs/TrajectoryTab";
import { KinematicsTab } from "./tabs/KinematicsTab";
import { RodStringTab } from "./tabs/RodStringTab";
import { PumpTab } from "./tabs/PumpTab";
import { FluidTab } from "./tabs/FluidTab";
import { SolverTab } from "./tabs/SolverTab";
import { ResultsTab } from "./tabs/ResultsTab";
import { AdvancedTab } from "./tabs/AdvancedTab";
import { runEngineeringChecks } from "../state/engineeringChecks";
const TABS: TabDef[] = [
{ id: "tab-well", label: "Well" },
{ id: "tab-trajectory", label: "Trajectory" },
{ id: "tab-kinematics", label: "Kinematics" },
{ id: "tab-rod", label: "Rod String" },
{ id: "tab-pump", label: "Pump" },
{ id: "tab-fluid", label: "Fluid" },
{ id: "tab-solver", label: "Solver" },
{ id: "tab-results", label: "Results" },
{ id: "tab-advanced", label: "Advanced / XML" }
];
export function App() {
const store = useCaseStore(EMPTY_CASE_STATE);
const [runSettings, setRunSettings] = useState<RunSettings>(INITIAL_RUN_SETTINGS);
const [activeTab, setActiveTab] = useState<string>(TABS[0].id);
const [result, setResult] = useState<SolveResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [elapsed, setElapsed] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState<string>("Loading base case…");
const [surfaceCardText, setSurfaceCardText] = useState<string>("");
const [surfaceCardQaMessage, setSurfaceCardQaMessage] = useState<string | null>(null);
const [surfaceCardQaError, setSurfaceCardQaError] = useState<string | null>(null);
const [validatingSurfaceCard, setValidatingSurfaceCard] = useState(false);
const hydrated = useRef(false);
const engineeringChecks = useMemo(() => runEngineeringChecks(store.state), [store.state]);
useEffect(() => {
if (hydrated.current) return;
hydrated.current = true;
(async () => {
try {
const parsed = await fetchDefaultCase();
store.setState(hydrateFromParsed(parsed));
setStatusMessage("Base case loaded — ready to edit / solve");
} catch (e) {
setStatusMessage("Failed to load base case");
setError(e instanceof Error ? e.message : String(e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const parsedSurfaceCard = useMemo(() => parseSurfaceCard(surfaceCardText), [surfaceCardText]);
const handleValidateSurfaceCard = useCallback(async () => {
setValidatingSurfaceCard(true);
setSurfaceCardQaMessage(null);
setSurfaceCardQaError(null);
try {
if (parsedSurfaceCard.errors.length) {
throw new Error(parsedSurfaceCard.errors.join(" "));
}
if (parsedSurfaceCard.position.length < 4) {
throw new Error("Need at least 4 points for a useful diagnostic surface card.");
}
const qa = await validateSurfaceCard({
position: parsedSurfaceCard.position,
load: parsedSurfaceCard.load
});
if (qa.ok) {
setSurfaceCardQaMessage(`QA OK (schema v${qa.schemaVersion}).`);
} else {
setSurfaceCardQaMessage("QA returned warnings. You can still run diagnostic solve.");
}
} catch (error) {
setSurfaceCardQaError(error instanceof Error ? error.message : String(error));
} finally {
setValidatingSurfaceCard(false);
}
}, [parsedSurfaceCard]);
const handleRun = useCallback(async () => {
if (engineeringChecks.hasBlockingError) {
setError("Please fix blocking engineering checks before running the solver.");
setStatusMessage("Blocked by engineering checks");
setActiveTab("tab-solver");
return;
}
setLoading(true);
setError(null);
setStatusMessage("Running solver…");
const t0 = performance.now();
try {
const xml = serializeCaseXml(store.state);
let surfaceCard: SurfaceCard | undefined;
if (runSettings.workflow === "diagnostic") {
if (parsedSurfaceCard.errors.length) {
throw new Error(`Diagnostic card input invalid: ${parsedSurfaceCard.errors.join(" ")}`);
}
if (parsedSurfaceCard.position.length < 4) {
throw new Error("Diagnostic workflow requires a surface card with at least 4 points.");
}
surfaceCard = {
position: parsedSurfaceCard.position,
load: parsedSurfaceCard.load
};
}
const resp = await solveCase({
xml,
solverModel: runSettings.solverModel,
workflow: runSettings.workflow,
surfaceCard,
options: {
enableProfiles: true,
enableDiagnosticsDetail: runSettings.workflow === "diagnostic"
}
});
setResult(resp);
const dt = (performance.now() - t0) / 1000;
setElapsed(dt);
setLastRunAt(new Date().toLocaleTimeString());
setStatusMessage(`Done in ${dt.toFixed(1)}s`);
setActiveTab("tab-results");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatusMessage("Error");
} finally {
setLoading(false);
}
}, [engineeringChecks.hasBlockingError, parsedSurfaceCard, runSettings, store.state]);
const handleExportXml = useCallback(() => {
const xml = serializeCaseXml(store.state);
const blob = new Blob([xml], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
const safeName = (store.state.wellName || "case").replace(/[^a-z0-9_.-]+/gi, "_");
anchor.download = `${safeName || "case"}.xml`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
setStatusMessage("XML exported");
}, [store.state]);
return (
<div className="app-shell">
<header className="app-header">
<div className="app-title">
<span className="app-logo" aria-hidden="true">
</span>
Rods-Cursor Case Editor & Solver
</div>
<div className="app-header-meta">
<span className="pill">{runSettings.solverModel.toUpperCase()}</span>
<span className="pill">{runSettings.workflow}</span>
{lastRunAt && <span className="pill">Last: {lastRunAt}</span>}
</div>
</header>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<main className="tab-body">
{activeTab === "tab-well" && <WellTab store={store} />}
{activeTab === "tab-trajectory" && <TrajectoryTab store={store} />}
{activeTab === "tab-kinematics" && (
<KinematicsTab
store={store}
surfaceCardText={surfaceCardText}
onSurfaceCardTextChange={setSurfaceCardText}
onValidateSurfaceCard={handleValidateSurfaceCard}
validatingSurfaceCard={validatingSurfaceCard}
surfaceCardQaMessage={surfaceCardQaMessage}
surfaceCardQaError={surfaceCardQaError}
/>
)}
{activeTab === "tab-rod" && <RodStringTab store={store} />}
{activeTab === "tab-pump" && <PumpTab store={store} />}
{activeTab === "tab-fluid" && <FluidTab store={store} />}
{activeTab === "tab-solver" && (
<SolverTab
store={store}
runSettings={runSettings}
onRunSettingsChange={setRunSettings}
onRun={handleRun}
onExportXml={handleExportXml}
loading={loading}
checks={engineeringChecks}
/>
)}
{activeTab === "tab-results" && (
<ResultsTab
result={result}
loading={loading}
error={error}
lastRunAt={lastRunAt}
elapsedSeconds={elapsed}
caseState={store.state}
checks={engineeringChecks}
onNavigateTab={setActiveTab}
/>
)}
{activeTab === "tab-advanced" && <AdvancedTab store={store} />}
</main>
<footer className="app-statusbar">
<span>{statusMessage}</span>
<span>Well: {store.state.wellName || "—"}</span>
<span>Taper sections: {store.state.taper.filter((t) => t.length > 0).length}</span>
<span>Survey stations: {store.state.survey.length}</span>
</footer>
</div>
);
}
function parseSurfaceCard(text: string): {
position: number[];
load: number[];
errors: string[];
} {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const position: number[] = [];
const load: number[] = [];
const errors: string[] = [];
for (let i = 0; i < lines.length; i += 1) {
const parts = lines[i].split(/[,\s;]+/).filter(Boolean);
if (parts.length < 2) {
errors.push(`Line ${i + 1} must contain position and load values.`);
continue;
}
const p = Number(parts[0]);
const l = Number(parts[1]);
if (!Number.isFinite(p) || !Number.isFinite(l)) {
errors.push(`Line ${i + 1} has non-numeric values.`);
continue;
}
position.push(p);
load.push(l);
}
return { position, load, errors };
}

50
gui-ts/src/ui/Tabs.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect } from "react";
export type TabDef = { id: string; label: string };
export type TabsProps = {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
ariaLabel?: string;
};
export function Tabs(props: TabsProps) {
useEffect(() => {
if (typeof window === "undefined") return;
const syncHash = () => {
const hash = window.location.hash.replace(/^#/, "");
if (hash && props.tabs.some((t) => t.id === hash) && hash !== props.active) {
props.onChange(hash);
}
};
window.addEventListener("hashchange", syncHash);
syncHash();
return () => window.removeEventListener("hashchange", syncHash);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.active]);
return (
<div className="tab-strip" role="tablist" aria-label={props.ariaLabel ?? "Tabs"}>
{props.tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={props.active === tab.id}
className={`tab ${props.active === tab.id ? "tab-active" : ""}`}
onClick={() => {
props.onChange(tab.id);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.hash = tab.id;
window.history.replaceState(null, "", url);
}
}}
>
{tab.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
export type CheckboxFieldProps = {
id?: string;
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
disabled?: boolean;
};
export function CheckboxField(props: CheckboxFieldProps) {
return (
<label className="panel-checkbox">
<input
id={props.id}
type="checkbox"
checked={props.checked}
disabled={props.disabled}
onChange={(e) => props.onChange(e.target.checked)}
/>
{props.label ? <span>{props.label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,13 @@
import type { PropsWithChildren } from "react";
export function Fieldset({
legend,
children
}: PropsWithChildren<{ legend: string }>) {
return (
<fieldset className="panel-fieldset">
<legend>{legend}</legend>
{children}
</fieldset>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
export type NumberFieldProps = {
id?: string;
value: number;
onChange: (value: number) => void;
step?: number;
min?: number;
max?: number;
placeholder?: string;
disabled?: boolean;
ariaLabel?: string;
};
/**
* Controlled numeric input that keeps a local string buffer so users can
* type partial values (e.g. "-", "0.", "1e") without being clobbered.
*/
export function NumberField(props: NumberFieldProps) {
const { value, onChange, ...rest } = props;
const [buffer, setBuffer] = useState<string>(Number.isFinite(value) ? String(value) : "");
useEffect(() => {
if (!Number.isFinite(value)) return;
setBuffer((prev) => {
const parsed = Number(prev);
if (Number.isFinite(parsed) && parsed === value) return prev;
return String(value);
});
}, [value]);
return (
<input
type="number"
className="panel-input"
aria-label={rest.ariaLabel}
id={rest.id}
step={rest.step ?? "any"}
min={rest.min}
max={rest.max}
placeholder={rest.placeholder}
disabled={rest.disabled}
value={buffer}
onChange={(e) => {
const next = e.target.value;
setBuffer(next);
const parsed = Number(next);
if (next === "") {
onChange(0);
} else if (Number.isFinite(parsed)) {
onChange(parsed);
}
}}
/>
);
}

View File

@@ -0,0 +1,29 @@
export type RadioOption<V extends string> = { value: V; label: string };
export type RadioGroupProps<V extends string> = {
name: string;
value: V;
onChange: (value: V) => void;
options: Array<RadioOption<V>>;
disabled?: boolean;
};
export function RadioGroup<V extends string>(props: RadioGroupProps<V>) {
return (
<div className="panel-radio-group" role="radiogroup">
{props.options.map((opt) => (
<label key={opt.value} className="panel-radio">
<input
type="radio"
name={props.name}
value={opt.value}
checked={props.value === opt.value}
disabled={props.disabled}
onChange={() => props.onChange(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import type { PropsWithChildren, ReactNode } from "react";
export function Row({
label,
htmlFor,
hint,
children
}: PropsWithChildren<{ label: ReactNode; htmlFor?: string; hint?: ReactNode }>) {
return (
<div className="panel-row">
<label htmlFor={htmlFor}>{label}</label>
<div className="panel-row-input">
{children}
{hint ? <div className="panel-row-hint">{hint}</div> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
export type SelectOption<V extends string | number> = {
value: V;
label: string;
};
export type SelectFieldProps<V extends string | number> = {
id?: string;
value: V;
onChange: (value: V) => void;
options: Array<SelectOption<V>>;
disabled?: boolean;
ariaLabel?: string;
};
export function SelectField<V extends string | number>(props: SelectFieldProps<V>) {
return (
<select
className="panel-input"
id={props.id}
aria-label={props.ariaLabel}
value={String(props.value)}
disabled={props.disabled}
onChange={(e) => {
const next = e.target.value;
const first = props.options[0];
if (typeof first?.value === "number") {
props.onChange(Number(next) as V);
} else {
props.onChange(next as V);
}
}}
>
{props.options.map((opt) => (
<option key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,23 @@
export type TextFieldProps = {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
ariaLabel?: string;
};
export function TextField(props: TextFieldProps) {
return (
<input
type="text"
className="panel-input"
id={props.id}
aria-label={props.ariaLabel}
value={props.value}
placeholder={props.placeholder}
disabled={props.disabled}
onChange={(e) => props.onChange(e.target.value)}
/>
);
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
import uPlot from "uplot";
import type { Options, AlignedData } from "uplot";
import "uplot/dist/uPlot.min.css";
export type UPlotChartProps = {
data: AlignedData;
options: Options;
height?: number;
};
/**
* Thin React wrapper around uPlot. Re-creates the chart whenever options
* identity changes and calls `setData` when data changes in place.
*/
export function UPlotChart(props: UPlotChartProps) {
const hostRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!hostRef.current) return undefined;
if (typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent)) {
return undefined;
}
// Skip under jsdom-style environments without a real 2D canvas context.
try {
const probe = document.createElement("canvas").getContext("2d");
if (!probe) return undefined;
} catch {
return undefined;
}
const opts: Options = {
...props.options,
width: hostRef.current.clientWidth || props.options.width || 600,
height: props.height ?? props.options.height ?? 260
};
const chart = new uPlot(opts, props.data, hostRef.current);
instanceRef.current = chart;
const resize = () => {
if (!hostRef.current || !instanceRef.current) return;
instanceRef.current.setSize({
width: hostRef.current.clientWidth || 600,
height: props.height ?? 260
});
};
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
chart.destroy();
instanceRef.current = null;
};
// Intentionally only re-create on options identity change; data updates handled below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.options, props.height]);
useEffect(() => {
instanceRef.current?.setData(props.data);
}, [props.data]);
return <div ref={hostRef} className="uplot-host" style={{ width: "100%" }} />;
}

View File

@@ -0,0 +1,342 @@
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
} from "../../state/trajectoryMetrics";
type ProjectionMode = "perspective" | "orthographic";
export type OverlayMode = "dls" | "sideLoad";
export type Wellbore3DViewProps = {
caseState: CaseState;
overlayMode?: OverlayMode;
sideLoadProfile?: number[] | null;
highlightedSegmentIndex?: number | null;
onSegmentSelect?: (segmentIndex: number | null) => void;
svgId?: string;
width?: number;
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 colorForSideLoad(value: number, max: number): string {
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 1e-6) return "#22c55e";
const ratio = Math.max(0, Math.min(1, value / max));
if (ratio >= 0.85) return "#ef4444";
if (ratio >= 0.45) return "#f59e0b";
return "#22c55e";
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function colorForRod(t: number): string {
const r = Math.round(lerp(56, 217, t));
const g = Math.round(lerp(189, 70, t));
const b = Math.round(lerp(248, 239, t));
return `rgb(${r}, ${g}, ${b})`;
}
function project(
p: TrajectoryPoint3D,
bounds: { minX: number; maxX: number; minY: number; maxY: number; minZ: number; maxZ: number },
width: number,
height: number,
view: {
yaw: number;
pitch: number;
zoom: number;
panX: number;
panY: number;
projection: ProjectionMode;
}
): { x: number; y: number } {
const cx = (bounds.minX + bounds.maxX) * 0.5;
const cy = (bounds.minY + bounds.maxY) * 0.5;
const cz = (bounds.minZ + bounds.maxZ) * 0.5;
let x = p.x - cx;
let y = p.y - cy;
let z = p.z - cz;
const yaw = view.yaw;
const pitch = view.pitch;
const x1 = x * Math.cos(yaw) - y * Math.sin(yaw);
const y1 = x * Math.sin(yaw) + y * Math.cos(yaw);
const z1 = z;
const x2 = x1;
const y2 = y1 * Math.cos(pitch) - z1 * Math.sin(pitch);
const z2 = y1 * Math.sin(pitch) + z1 * Math.cos(pitch);
x = x2;
y = y2;
z = z2;
const extent = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY, bounds.maxZ - bounds.minZ, 1);
const scale = ((Math.min(width, height) * 0.72) / extent) * view.zoom;
const depth = view.projection === "perspective" ? 1 / (1 + z * 0.001) : 1;
return {
x: width * 0.5 + view.panX + x * scale * depth,
y: height * 0.5 + view.panY - y * scale * depth
};
}
export function Wellbore3DView({
caseState,
overlayMode = "dls",
sideLoadProfile = null,
highlightedSegmentIndex = null,
onSegmentSelect,
svgId = "wellbore-3d-svg",
width = 840,
height = 420
}: Wellbore3DViewProps) {
const [view, setView] = useState({
yaw: 0.8,
pitch: 0.55,
zoom: 1,
panX: 0,
panY: 0,
projection: "perspective" as ProjectionMode
});
const dragState = useRef<{
active: boolean;
mode: "rotate" | "pan";
x: number;
y: number;
}>({ active: false, mode: "rotate", x: 0, y: 0 });
const geom = useMemo(() => {
const segments = buildTrajectorySegments(caseState.survey);
if (!segments.length) return null;
const points = [segments[0].a, ...segments.map((s) => s.b)];
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const zs = points.map((p) => p.z);
const bounds = {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
minZ: Math.min(...zs),
maxZ: Math.max(...zs)
};
const rodLength = caseState.taper.reduce((sum, row) => sum + Math.max(0, row.length), 0);
const pumpPoint = interpolateAlongMd(segments, caseState.pumpDepth);
const sideLoadMax = sideLoadProfile?.length
? Math.max(...sideLoadProfile.filter((v) => Number.isFinite(v)), 0)
: 0;
return { segments, bounds, rodLength, pumpPoint, sideLoadMax };
}, [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 (
<div className="wellbore-3d-wrap">
<div className="button-row">
<button
type="button"
className="btn"
onClick={() =>
setView((prev) => ({
...prev,
projection: prev.projection === "perspective" ? "orthographic" : "perspective"
}))
}
>
Projection: {view.projection}
</button>
<button
type="button"
className="btn"
onClick={() => setView((prev) => ({ ...prev, zoom: Math.min(prev.zoom * 1.15, 4) }))}
>
Zoom +
</button>
<button
type="button"
className="btn"
onClick={() => setView((prev) => ({ ...prev, zoom: Math.max(prev.zoom / 1.15, 0.35) }))}
>
Zoom -
</button>
<button
type="button"
className="btn"
onClick={() =>
setView({
yaw: 0.8,
pitch: 0.55,
zoom: 1,
panX: 0,
panY: 0,
projection: "perspective"
})
}
>
Reset View
</button>
<span className="panel-note" style={{ marginLeft: "auto", marginBottom: 0 }}>
Drag: rotate | Shift+drag: pan | Mouse wheel: zoom
</span>
</div>
<svg
id={svgId}
className="wellbore-3d"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label="3D wellbore view"
>
<title>3D Wellbore Viewer</title>
<rect x={0} y={0} width={width} height={height} fill="#020617" stroke="#334155" />
<rect
x={0}
y={0}
width={width}
height={height}
fill="transparent"
onClick={() => onSegmentSelect?.(null)}
onPointerDown={(event) => {
dragState.current = {
active: true,
mode: event.shiftKey ? "pan" : "rotate",
x: event.clientX,
y: event.clientY
};
(event.currentTarget as SVGRectElement).setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (!dragState.current.active) return;
const dx = event.clientX - dragState.current.x;
const dy = event.clientY - dragState.current.y;
dragState.current.x = event.clientX;
dragState.current.y = event.clientY;
if (dragState.current.mode === "pan") {
setView((prev) => ({ ...prev, panX: prev.panX + dx, panY: prev.panY + dy }));
} else {
setView((prev) => ({
...prev,
yaw: prev.yaw + dx * 0.006,
pitch: Math.max(-1.25, Math.min(1.25, prev.pitch + dy * 0.004))
}));
}
}}
onPointerUp={(event) => {
dragState.current.active = false;
(event.currentTarget as SVGRectElement).releasePointerCapture(event.pointerId);
}}
onPointerLeave={() => {
dragState.current.active = false;
}}
onWheel={(event) => {
event.preventDefault();
const factor = event.deltaY < 0 ? 1.06 : 1 / 1.06;
setView((prev) => ({
...prev,
zoom: Math.max(0.35, Math.min(4, prev.zoom * factor))
}));
}}
/>
{geom.segments.map((segment, idx) => {
const a = project(segment.a, geom.bounds, width, height, view);
const b = project(segment.b, geom.bounds, width, height, view);
const sideLoad =
sideLoadProfile && sideLoadProfile.length
? sideLoadProfile[Math.min(idx, sideLoadProfile.length - 1)] ?? 0
: 0;
const stroke =
overlayMode === "sideLoad"
? colorForSideLoad(sideLoad, geom.sideLoadMax)
: colorForDls(segment.dls);
const active = highlightedSegmentIndex === idx;
return (
<line
key={`tube-${idx}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={stroke}
strokeWidth={active ? 6 : 4}
strokeLinecap="round"
opacity={active ? 1 : 0.75}
onClick={() => onSegmentSelect?.(idx)}
style={{ cursor: "pointer" }}
/>
);
})}
{geom.segments.map((segment, idx) => {
const rodEndMd = geom.rodLength;
if (segment.a.md > rodEndMd) return null;
const clippedEnd = segment.b.md > rodEndMd
? interpolateAlongMd([segment], rodEndMd)
: segment.b;
if (!clippedEnd) return null;
const a = project(segment.a, geom.bounds, width, height, view);
const b = project(clippedEnd, geom.bounds, width, height, view);
const t = Math.min(1, Math.max(0, clippedEnd.md / Math.max(rodEndMd, 1)));
return (
<line
key={`rod-${idx}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={colorForRod(t)}
strokeWidth={2}
strokeLinecap="round"
/>
);
})}
{geom.pumpPoint && (
(() => {
const p = project(geom.pumpPoint as TrajectoryPoint3D, geom.bounds, width, height, view);
return (
<g>
<circle cx={p.x} cy={p.y} r={5.5} fill="#e11d48" />
<circle cx={p.x} cy={p.y} r={10} fill="none" stroke="#fb7185" strokeWidth={1.4} />
</g>
);
})()
)}
</svg>
<div className="wellbore-legend">
{overlayMode === "dls" ? (
<>
<span><i style={{ background: "#22c55e" }} />Low DLS (&lt; {(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>
</>
) : (
<>
<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)" }} />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>
);
}

View File

@@ -0,0 +1,33 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Wellbore3DView } from "../Wellbore3DView";
import { EMPTY_CASE_STATE } from "../../../state/caseModel";
describe("Wellbore3DView controls", () => {
it("renders projection/zoom/reset controls and toggles projection", () => {
const state = {
...EMPTY_CASE_STATE,
pumpDepth: 1000,
survey: [
{ md: 0, inc: 0, azi: 0 },
{ md: 500, inc: 15, azi: 35 },
{ md: 1000, inc: 30, azi: 65 }
],
taper: [{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }]
};
render(<Wellbore3DView caseState={state} />);
const projectionBtn = screen.getByRole("button", { name: /Projection:/i });
expect(projectionBtn).toHaveTextContent("Projection: perspective");
fireEvent.click(projectionBtn);
expect(screen.getByRole("button", { name: /Projection:/i })).toHaveTextContent(
"Projection: orthographic"
);
expect(screen.getByRole("button", { name: "Zoom +" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Zoom -" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset View" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
import { useState } from "react";
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { serializeCaseXml } from "../../state/xmlExport";
import { hydrateFromParsed } from "../../state/xmlImport";
import { parseCaseXmlApi } from "../../api/client";
import { textOf, describeRawField } from "./rawFieldHelpers";
type Props = {
store: CaseStore;
};
export function AdvancedTab({ store }: Props) {
const { state, setState } = store;
const [pasted, setPasted] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function importXml(xml: string) {
setBusy(true);
setError(null);
setMessage(null);
try {
const parsed = await parseCaseXmlApi(xml);
const next = hydrateFromParsed(parsed);
setState(next);
setMessage(
`Imported case with ${Object.keys(parsed.rawFields).length} XML fields (${parsed.unsupportedFields.length} unsupported).`
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
async function onFile(file: File) {
const text = await file.text();
setPasted(text);
void importXml(text);
}
function exportXml() {
const xml = serializeCaseXml(state);
const blob = new Blob([xml], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
const safeName = (state.wellName || "case").replace(/[^a-z0-9_.-]+/gi, "_");
anchor.download = `${safeName || "case"}.xml`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
setMessage("Exported current state as XML.");
setError(null);
}
return (
<>
<Fieldset legend="Import Case XML">
<p className="panel-note">
Upload a case XML file or paste its contents. Parsing is performed by
<code> POST /case/parse</code> in the solver-api so the result
matches the canonical parser exactly.
</p>
<div className="button-row">
<label className="btn btn-secondary">
Choose file
<input
type="file"
accept=".xml,application/xml,text/xml"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void onFile(file);
}}
/>
</label>
<button
type="button"
className="btn"
disabled={busy || !pasted.trim()}
onClick={() => void importXml(pasted)}
>
Parse pasted XML
</button>
<button type="button" className="btn btn-primary" onClick={exportXml}>
Export current state as XML
</button>
</div>
<textarea
className="advanced-textarea"
placeholder="<INPRoot>…</INPRoot>"
value={pasted}
onChange={(e) => setPasted(e.target.value)}
rows={14}
/>
{message && <div className="callout callout-info">{message}</div>}
{error && <div className="callout callout-error">{error}</div>}
</Fieldset>
<Fieldset
legend={`Raw XML fields (${state.rawFieldOrder.length})`}
>
<p className="panel-note">
Every element from the loaded XML is stored here and round-tripped on
export. Fields with a first-class editor in another tab are shown
read-only.
</p>
<div className="table-scroll" style={{ maxHeight: 360 }}>
<table className="data-table">
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{state.rawFieldOrder.map((key) => (
<tr key={key}>
<td style={{ fontFamily: "monospace" }}>{key}</td>
<td title={describeRawField(state.rawFields[key])}>
<code>{truncate(textOf(state.rawFields[key]))}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Fieldset>
</>
);
}
function truncate(value: string, max = 120): string {
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}`;
}

View File

@@ -0,0 +1,57 @@
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { Row } from "../common/Row";
import { NumberField } from "../common/NumberField";
type Props = { store: CaseStore };
export function FluidTab({ store }: Props) {
const { state, update } = store;
return (
<div className="tab-grid two">
<Fieldset legend="Fluid Properties">
<Row label="Water Cut (%)" htmlFor="waterCut">
<NumberField
id="waterCut"
value={state.waterCut}
step={1}
min={0}
max={100}
onChange={(v) => update("waterCut", v)}
/>
</Row>
<Row label="Water Specific Gravity" htmlFor="waterSG">
<NumberField
id="waterSG"
value={state.waterSpecGravity}
step={0.001}
onChange={(v) => update("waterSpecGravity", v)}
/>
</Row>
<Row label="Oil API Gravity" htmlFor="oilAPI">
<NumberField
id="oilAPI"
value={state.fluidLevelOilGravity}
step={1}
onChange={(v) => update("fluidLevelOilGravity", v)}
/>
</Row>
</Fieldset>
<Fieldset legend="Tubing Hydraulics">
<Row
label="Tubing Gradient"
htmlFor="tubingGrad"
hint="psi/ft in imperial units; converted to Pa/m internally"
>
<NumberField
id="tubingGrad"
value={state.tubingGradient}
step={0.01}
onChange={(v) => update("tubingGradient", v)}
/>
</Row>
</Fieldset>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { Row } from "../common/Row";
import { NumberField } from "../common/NumberField";
import { TextField } from "../common/TextField";
import { SelectField } from "../common/SelectField";
type Props = {
store: CaseStore;
surfaceCardText: string;
onSurfaceCardTextChange: (value: string) => void;
onValidateSurfaceCard: () => void;
validatingSurfaceCard: boolean;
surfaceCardQaMessage: string | null;
surfaceCardQaError: string | null;
};
export function KinematicsTab({
store,
surfaceCardText,
onSurfaceCardTextChange,
onValidateSurfaceCard,
validatingSurfaceCard,
surfaceCardQaMessage,
surfaceCardQaError
}: Props) {
const { state, update } = store;
return (
<>
<Fieldset legend="Pumping Unit / Surface Motion">
<p className="panel-note">
Drives the polished-rod boundary condition via pumping speed (SPM).
Diagnostic workflow with measured surface-card upload is planned for
a future pass (see <code>solver-api POST /solve/validate-card</code>).
</p>
<Row label="Pumping Speed (SPM)" htmlFor="pumpingSpeed">
<NumberField
id="pumpingSpeed"
value={state.pumpingSpeed}
step={0.1}
min={0.5}
onChange={(v) => update("pumpingSpeed", v)}
/>
</Row>
<Row label="Pumping Speed Option" htmlFor="pumpingSpeedOption">
<SelectField
id="pumpingSpeedOption"
value={state.pumpingSpeedOption}
onChange={(v) => update("pumpingSpeedOption", v)}
options={[
{ value: 0, label: "0 — legacy / unknown" },
{ value: 1, label: "1 — fixed SPM" },
{ value: 2, label: "2 — VFD-driven" }
]}
/>
</Row>
<Row label="Pumping Unit ID" htmlFor="pumpingUnitId">
<TextField
id="pumpingUnitId"
value={state.pumpingUnitId}
onChange={(v) => update("pumpingUnitId", v)}
/>
</Row>
</Fieldset>
<Fieldset legend="Stroke Timing">
<Row label="% Upstroke time" htmlFor="percentUp">
<NumberField
id="percentUp"
value={state.percentUpstrokeTime}
step={1}
min={10}
max={90}
onChange={(v) => update("percentUpstrokeTime", v)}
/>
</Row>
<Row label="% Downstroke time" htmlFor="percentDown">
<NumberField
id="percentDown"
value={state.percentDownstrokeTime}
step={1}
min={10}
max={90}
onChange={(v) => update("percentDownstrokeTime", v)}
/>
</Row>
</Fieldset>
<Fieldset legend="Diagnostic Surface Card Input">
<p className="panel-note">
Paste measured card rows as <code>position,load</code> pairs (one per line).
Example:
<br />
<code>-1.2,12000</code>
<br />
<code>-0.6,13200</code>
</p>
<textarea
className="advanced-textarea"
rows={8}
value={surfaceCardText}
onChange={(e) => onSurfaceCardTextChange(e.target.value)}
placeholder="-1.2,12000&#10;-0.6,13200&#10;..."
/>
<div className="button-row">
<button
type="button"
className="btn"
disabled={validatingSurfaceCard || !surfaceCardText.trim()}
onClick={onValidateSurfaceCard}
>
{validatingSurfaceCard ? "Validating…" : "Validate Surface Card"}
</button>
</div>
{surfaceCardQaMessage && <div className="callout callout-info">{surfaceCardQaMessage}</div>}
{surfaceCardQaError && <div className="callout callout-error">{surfaceCardQaError}</div>}
</Fieldset>
</>
);
}

View File

@@ -0,0 +1,70 @@
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";
type Props = { store: CaseStore };
export function PumpTab({ store }: Props) {
const { state, update } = store;
return (
<div className="tab-grid two">
<Fieldset legend="Pump Geometry">
<Row
label="Plunger Diameter"
htmlFor="plungerDiam"
hint="mm in base-case XML (converted to m if > 2)"
>
<NumberField
id="plungerDiam"
value={state.pumpDiameter}
step={0.25}
onChange={(v) => update("pumpDiameter", v)}
/>
</Row>
<Row label="Pump Friction" htmlFor="pumpFric" hint="lbf (imperial)">
<NumberField
id="pumpFric"
value={state.pumpFriction}
step={10}
onChange={(v) => update("pumpFriction", v)}
/>
</Row>
<Row label="Pump Intake Pressure" htmlFor="pumpIntake" hint="psi (imperial)">
<NumberField
id="pumpIntake"
value={state.pumpIntakePressure}
step={1}
onChange={(v) => update("pumpIntakePressure", v)}
/>
</Row>
</Fieldset>
<Fieldset legend="Fillage">
<Row label="Pump Fillage Option" htmlFor="fillageOpt">
<SelectField
id="fillageOpt"
value={state.pumpFillageOption}
onChange={(v) => update("pumpFillageOption", v)}
options={[
{ value: 0, label: "0 — auto" },
{ value: 1, label: "1 — specified" },
{ value: 2, label: "2 — incomplete fillage" }
]}
/>
</Row>
<Row label="Percent Pump Fillage (%)" htmlFor="pctFill">
<NumberField
id="pctFill"
value={state.percentPumpFillage}
step={1}
min={0}
max={100}
onChange={(v) => update("percentPumpFillage", v)}
/>
</Row>
</Fieldset>
</div>
);
}

View File

@@ -0,0 +1,536 @@
import { useMemo, useState } from "react";
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";
import { Wellbore3DView } from "../common/Wellbore3DView";
import type { OverlayMode } from "../common/Wellbore3DView";
type Props = {
result: SolveResponse | null;
loading: boolean;
error: string | null;
lastRunAt: string | null;
elapsedSeconds: number | null;
caseState: CaseState;
checks: EngineeringChecks;
onNavigateTab?: (tabId: string) => void;
};
type CardSeries = {
position: number[];
polished: number[];
downhole: number[];
};
function toSeries(solver: SolverOutput | undefined): CardSeries | null {
if (!solver || !solver.card?.length) return null;
return {
position: solver.card.map((p) => p.position),
polished: solver.card.map((p) => p.polishedLoad),
downhole: solver.card.map((p) => p.downholeLoad)
};
}
function formatKn(value: number | undefined): string {
if (value === undefined || !Number.isFinite(value)) return "—";
return (value / 1000).toFixed(2);
}
function computePumpPlacement(caseState: CaseState) {
const segments = buildTrajectorySegments(caseState.survey);
const taperLen = caseState.taper.reduce((sum, row) => sum + Math.max(0, row.length), 0);
if (!segments.length) {
return {
nearestStationIndex: -1,
nearestStationMd: null as number | null,
nearestStationDistance: null as number | null,
surveyEndMd: null as number | null,
surveyToPumpDelta: null as number | null,
rodToPumpDelta: taperLen - caseState.pumpDepth
};
}
const stationMds = [segments[0].a.md, ...segments.map((s) => s.b.md)];
let nearestIdx = 0;
let nearestDist = Math.abs(stationMds[0] - caseState.pumpDepth);
for (let i = 1; i < stationMds.length; i += 1) {
const d = Math.abs(stationMds[i] - caseState.pumpDepth);
if (d < nearestDist) {
nearestDist = d;
nearestIdx = i;
}
}
const surveyEndMd = stationMds[stationMds.length - 1];
return {
nearestStationIndex: nearestIdx,
nearestStationMd: stationMds[nearestIdx],
nearestStationDistance: nearestDist,
surveyEndMd,
surveyToPumpDelta: surveyEndMd - caseState.pumpDepth,
rodToPumpDelta: taperLen - caseState.pumpDepth
};
}
export function ResultsTab({
result,
loading,
error,
lastRunAt,
elapsedSeconds,
caseState,
checks,
onNavigateTab
}: Props) {
const primary = result?.solver;
const fea = result?.solvers?.fea ?? null;
const fdm = result?.solvers?.fdm ?? primary ?? null;
const [overlayMode, setOverlayMode] = useState<OverlayMode>("dls");
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]);
const dynacardData = useMemo<AlignedData | null>(() => {
const fdmSeries = toSeries(fdm ?? undefined);
const feaSeries = toSeries(fea ?? undefined);
if (!fdmSeries) return null;
if (feaSeries && feaSeries.position.length === fdmSeries.position.length) {
return [
fdmSeries.position,
fdmSeries.polished,
fdmSeries.downhole,
feaSeries.polished,
feaSeries.downhole
] as AlignedData;
}
return [fdmSeries.position, fdmSeries.polished, fdmSeries.downhole] as AlignedData;
}, [fdm, fea]);
const dynacardOptions = useMemo<Options>(() => {
const seriesCount = fea ? 5 : 3;
const seriesSpec: Options["series"] = [
{ label: "Position (m)" },
{ label: "Polished (FDM)", stroke: "#f59e0b", width: 2 },
{ label: "Downhole (FDM)", stroke: "#22d3ee", width: 2 }
];
if (seriesCount === 5) {
seriesSpec.push({ label: "Polished (FEA)", stroke: "#f43f5e", width: 1.5, dash: [6, 3] });
seriesSpec.push({ label: "Downhole (FEA)", stroke: "#34d399", width: 1.5, dash: [6, 3] });
}
return {
width: 800,
height: 320,
scales: { x: { time: false } },
axes: [
{ label: "Polished-rod position (m)", stroke: "#cbd5f5" },
{ label: "Load (N)", stroke: "#cbd5f5" }
],
series: seriesSpec,
legend: { show: true }
} satisfies Options;
}, [fea]);
return (
<>
{loading && (
<div className="callout callout-info">
<span className="spinner" /> Running simulation
</div>
)}
{error && (
<div className="callout callout-error">
<strong>Error:</strong> {error}
</div>
)}
{!result && !loading && !error && (
<div className="callout">
No results yet. Edit inputs and press <strong>Run Solver</strong>.
</div>
)}
{!!checks.issues.length && (
<Fieldset legend="Input Integrity Checks">
<ul className="warning-list">
{checks.issues.map((issue) => (
<li key={issue.code}>
<strong>{issue.severity.toUpperCase()}:</strong> {issue.message}
</li>
))}
</ul>
</Fieldset>
)}
<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
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"}
>
Overlay: DLS
</button>
<button
type="button"
className={`btn ${overlayMode === "sideLoad" ? "btn-primary" : ""}`}
onClick={() => setOverlayMode("sideLoad")}
disabled={!sideLoadProfile?.length}
title={!sideLoadProfile?.length ? "Run with profile output to enable side-load overlay" : ""}
aria-pressed={overlayMode === "sideLoad"}
>
Overlay: Side-load risk
</button>
<button
type="button"
className="btn"
onClick={() => setSelectedSegment(null)}
disabled={selectedSegment === null}
title="Clear selected trajectory segment highlight"
>
Clear segment highlight
</button>
<button
type="button"
className="btn"
onClick={() => exportSvg("wellbore-3d-svg", `${caseState.wellName || "well"}_wellbore.svg`)}
title="Export the current 3D viewer as SVG"
>
Export 3D SVG
</button>
<button
type="button"
className="btn"
onClick={() =>
exportSvgToPng("wellbore-3d-svg", `${caseState.wellName || "well"}_wellbore.png`)
}
title="Export the current 3D viewer as PNG"
>
Export 3D PNG
</button>
<button
type="button"
className="btn"
onClick={() =>
exportSummaryJson(
`${caseState.wellName || "well"}_summary.json`,
result,
checks,
pumpDiag
)
}
title="Export current checks, diagnostics, and run metadata"
>
Export summary JSON
</button>
</div>
<Wellbore3DView
caseState={caseState}
overlayMode={overlayMode}
sideLoadProfile={sideLoadProfile}
highlightedSegmentIndex={selectedSegment}
onSegmentSelect={setSelectedSegment}
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>
<div className="table-scroll" style={{ maxHeight: 250 }}>
<table className="data-table">
<thead>
<tr>
<th>#</th>
<th>MD start</th>
<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 (
<tr
key={segment.index}
className={selectedSegment === segment.index ? "row-selected" : ""}
onClick={() => 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" }}
>
<td>{segment.index + 1}</td>
<td>{segment.a.md.toFixed(1)}</td>
<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 && (
<tr>
<td colSpan={6} className="empty-row">No trajectory segments to display.</td>
</tr>
)}
</tbody>
</table>
</div>
</Fieldset>
<Fieldset legend="Pump Placement Diagnostics">
<div className="kpi-grid">
<Kpi
label="Nearest station index"
value={pumpDiag.nearestStationIndex >= 0 ? String(pumpDiag.nearestStationIndex + 1) : "—"}
/>
<Kpi
label="Nearest station MD"
value={pumpDiag.nearestStationMd !== null ? pumpDiag.nearestStationMd.toFixed(1) : "—"}
/>
<Kpi
label="Pump-to-nearest station ΔMD"
value={
pumpDiag.nearestStationDistance !== null
? `${pumpDiag.nearestStationDistance.toFixed(1)}`
: "—"
}
/>
<Kpi
label="Survey end - pump ΔMD"
value={pumpDiag.surveyToPumpDelta !== null ? pumpDiag.surveyToPumpDelta.toFixed(1) : "—"}
/>
<Kpi label="Rod total - pump Δ" value={pumpDiag.rodToPumpDelta.toFixed(1)} />
<Kpi
label="Tubing anchor - pump Δ"
value={(caseState.tubingAnchorLocation - caseState.pumpDepth).toFixed(1)}
/>
</div>
<div className="button-row">
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-trajectory")}>
Go to Trajectory tab
</button>
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-rod")}>
Go to Rod String tab
</button>
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-well")}>
Go to Well tab
</button>
</div>
</Fieldset>
{result && (
<>
<Fieldset legend="Summary — Key Performance Indicators">
<div className="kpi-grid">
<Kpi label="Active solver" value={(result.runMetadata.solverModel || "fdm").toUpperCase()} />
<Kpi label="Workflow" value={result.runMetadata.workflow ?? "predictive"} />
<Kpi label="Schema" value={`v${result.schemaVersion ?? 2}`} />
<Kpi label="Last run" value={lastRunAt ?? "—"} />
<Kpi label="Elapsed" value={elapsedSeconds !== null ? `${elapsedSeconds.toFixed(1)} s` : "—"} />
<Kpi label="Well" value={result.parsed.model.wellName} />
<Kpi label="Surface peak (kN)" value={formatKn(primary?.maxPolishedLoad)} />
<Kpi label="Surface min (kN)" value={formatKn(primary?.minPolishedLoad)} />
<Kpi label="Downhole peak (kN)" value={formatKn(primary?.maxDownholeLoad)} />
<Kpi label="Downhole min (kN)" value={formatKn(primary?.minDownholeLoad)} />
<Kpi label="Point count" value={String(primary?.pointCount ?? "—")} />
<Kpi
label="Gas interference"
value={primary?.gasInterference ? "Yes" : "No"}
/>
</div>
</Fieldset>
<Fieldset legend="Dynamometer Card">
{dynacardData ? (
<UPlotChart data={dynacardData} options={dynacardOptions} height={340} />
) : (
<p className="panel-note">No card data in response.</p>
)}
</Fieldset>
{result.comparison && (
<Fieldset legend="FDM vs FEA Comparison">
<div className="kpi-grid">
<Kpi
label="ΔPolished max (N)"
value={result.comparison.polishedMaxDelta.toFixed(2)}
/>
<Kpi
label="ΔPolished min (N)"
value={result.comparison.polishedMinDelta.toFixed(2)}
/>
<Kpi
label="ΔDownhole max (N)"
value={result.comparison.downholeMaxDelta.toFixed(2)}
/>
<Kpi
label="ΔDownhole min (N)"
value={result.comparison.downholeMinDelta.toFixed(2)}
/>
<Kpi
label="Residual RMS (N)"
value={result.comparison.residualSummary?.rms.toFixed(2) ?? "—"}
/>
<Kpi
label="Residual points"
value={String(result.comparison.residualSummary?.points ?? "—")}
/>
</div>
</Fieldset>
)}
{(result.parseWarnings?.length || primary?.warnings?.length) && (
<Fieldset legend="Warnings">
<ul className="warning-list">
{(result.parseWarnings ?? []).map((w, i) => (
<li key={`pw-${i}`}>{w}</li>
))}
{(primary?.warnings ?? []).map((w, i) => (
<li key={`sw-${i}`}>{w}</li>
))}
</ul>
</Fieldset>
)}
{result.parsed.unsupportedFields?.length ? (
<Fieldset legend={`Unsupported XML fields (${result.parsed.unsupportedFields.length})`}>
<p className="panel-note">
Preserved on export; not consumed by the solver.
</p>
<pre className="mono-block">
{result.parsed.unsupportedFields.join("\n")}
</pre>
</Fieldset>
) : null}
</>
)}
</>
);
}
function exportSvg(svgId: string, filename: string) {
const svg = document.getElementById(svgId);
if (!svg) return;
const serializer = new XMLSerializer();
const source = serializer.serializeToString(svg);
const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function exportSvgToPng(svgId: string, filename: string) {
const svg = document.getElementById(svgId) as SVGSVGElement | null;
if (!svg) return;
const serializer = new XMLSerializer();
const source = serializer.serializeToString(svg);
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const vb = svg.viewBox.baseVal;
const width = vb.width || svg.clientWidth || 800;
const height = vb.height || svg.clientHeight || 400;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (!blob) return;
const pngUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = pngUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(pngUrl);
}, "image/png");
URL.revokeObjectURL(url);
};
img.src = url;
}
function exportSummaryJson(
filename: string,
result: SolveResponse | null,
checks: EngineeringChecks,
pumpDiag: ReturnType<typeof computePumpPlacement>
) {
const payload = {
exportedAt: new Date().toISOString(),
checks,
pumpPlacement: pumpDiag,
runMetadata: result?.runMetadata ?? null,
comparison: result?.comparison ?? null,
warnings: {
parse: result?.parseWarnings ?? [],
solver: result?.solver?.warnings ?? []
}
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function Kpi({ label, value }: { label: string; value: string }) {
return (
<div className="kpi-cell">
<div className="kpi-label">{label}</div>
<div className="kpi-val">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useMemo } from "react";
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { NumberField } from "../common/NumberField";
import { SelectField } from "../common/SelectField";
type Props = { store: CaseStore };
const ROD_TYPE_OPTIONS = [
{ value: 0, label: "0 — steel (generic)" },
{ value: 1, label: "1 — steel (alt. grade)" },
{ value: 2, label: "2 — sinker bar" },
{ value: 3, label: "3 — fiberglass" }
];
export function RodStringTab({ store }: Props) {
const { state, addTaperRow, removeTaperRow, updateTaperRow, setTaper } = store;
const totals = useMemo(() => {
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]);
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 }
]);
}
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 &gt; 2 as millimetres and converts to SI.
Modulus 1e8 is treated as Pa; otherwise as Mpsi.
</p>
<div className="button-row">
<button type="button" className="btn" onClick={() => addTaperRow()}>
Add Section
</button>
<button type="button" className="btn" onClick={() => setTaper([])}>
Clear All
</button>
<button type="button" className="btn" onClick={loadDefaultString}>
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)}
</span>
</div>
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th style={{ width: 40 }}>#</th>
<th>Diameter</th>
<th>Length</th>
<th>Modulus</th>
<th>Rod Type</th>
<th style={{ width: 60 }}></th>
</tr>
</thead>
<tbody>
{state.taper.map((row, i) => (
<tr key={i}>
<td>{i + 1}</td>
<td>
<NumberField
value={row.diameter}
onChange={(v) => updateTaperRow(i, { diameter: v })}
ariaLabel={`Taper ${i + 1} diameter`}
/>
</td>
<td>
<NumberField
value={row.length}
onChange={(v) => updateTaperRow(i, { length: v })}
ariaLabel={`Taper ${i + 1} length`}
/>
</td>
<td>
<NumberField
value={row.modulus}
step={0.1}
onChange={(v) => updateTaperRow(i, { modulus: v })}
ariaLabel={`Taper ${i + 1} modulus`}
/>
</td>
<td>
<SelectField
value={row.rodType}
options={ROD_TYPE_OPTIONS}
onChange={(v) => updateTaperRow(i, { rodType: v })}
/>
</td>
<td>
<button
type="button"
className="btn btn-danger"
onClick={() => removeTaperRow(i)}
aria-label={`Remove taper ${i + 1}`}
>
</button>
</td>
</tr>
))}
{state.taper.length === 0 && (
<tr>
<td colSpan={6} className="empty-row">
No taper sections. Add rows or load the base-case string.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Fieldset>
);
}

View File

@@ -0,0 +1,171 @@
import type { CaseStore } from "../../state/useCaseStore";
import type { RunSettings } from "../../state/caseModel";
import type { EngineeringChecks } from "../../state/engineeringChecks";
import { Fieldset } from "../common/Fieldset";
import { Row } from "../common/Row";
import { NumberField } from "../common/NumberField";
import { RadioGroup } from "../common/RadioGroup";
type Props = {
store: CaseStore;
runSettings: RunSettings;
onRunSettingsChange: (next: RunSettings) => void;
onRun: () => void;
onExportXml: () => void;
loading: boolean;
checks: EngineeringChecks;
};
export function SolverTab({
store,
runSettings,
onRunSettingsChange,
onRun,
onExportXml,
loading,
checks
}: Props) {
const { state, update } = store;
const warningIssues = checks.issues.filter((issue) => issue.severity === "warning");
const errorIssues = checks.issues.filter((issue) => issue.severity === "error");
return (
<>
{!!checks.issues.length && (
<Fieldset legend="Engineering Checks">
{!!errorIssues.length && (
<div className="callout callout-error" style={{ marginBottom: warningIssues.length ? 8 : 0 }}>
<strong>Blocking errors ({errorIssues.length})</strong>
<ul className="warning-list">
{errorIssues.map((issue) => (
<li key={issue.code}>{issue.message}</li>
))}
</ul>
</div>
)}
{!!warningIssues.length && (
<div className="callout callout-warning">
<strong>Warnings ({warningIssues.length})</strong>
<ul className="warning-list">
{warningIssues.map((issue) => (
<li key={issue.code}>{issue.message}</li>
))}
</ul>
</div>
)}
</Fieldset>
)}
<Fieldset legend="Solver Selection">
<p className="panel-note">
<strong>FDM (Gibbs):</strong> extended finite-difference solution of
the damped wave equation.
<br />
<strong>FEA:</strong> dynamic beam-element FEM (Newmark-β) more
accurate for highly deviated wells.
</p>
<RadioGroup
name="solverModel"
value={runSettings.solverModel}
onChange={(v) => onRunSettingsChange({ ...runSettings, solverModel: v })}
options={[
{ value: "fdm", label: "FDM (fast)" },
{ value: "fea", label: "FEA (rigorous)" },
{ value: "both", label: "Run both + compare" }
]}
/>
</Fieldset>
<Fieldset legend="Workflow">
<RadioGroup
name="workflow"
value={runSettings.workflow}
onChange={(v) => onRunSettingsChange({ ...runSettings, workflow: v })}
options={[
{ value: "predictive", label: "Predictive (synthesize surface motion)" },
{
value: "diagnostic",
label: "Diagnostic (uses measured surface card from Kinematics tab)"
}
]}
/>
</Fieldset>
<div className="tab-grid two">
<Fieldset legend="Damping">
<Row label="Up-stroke damping">
<NumberField
value={state.upStrokeDamping}
step={0.01}
onChange={(v) => update("upStrokeDamping", v)}
/>
</Row>
<Row label="Down-stroke damping">
<NumberField
value={state.downStrokeDamping}
step={0.01}
onChange={(v) => update("downStrokeDamping", v)}
/>
</Row>
<Row label="Non-dim. fluid damping">
<NumberField
value={state.nonDimensionalFluidDamping}
step={0.1}
onChange={(v) => update("nonDimensionalFluidDamping", v)}
/>
</Row>
</Fieldset>
<Fieldset legend="Friction Coefficients">
<Row label="Rod friction coeff.">
<NumberField
value={state.rodFrictionCoefficient}
step={0.01}
onChange={(v) => update("rodFrictionCoefficient", v)}
/>
</Row>
<Row label="Stuffing-box friction">
<NumberField
value={state.stuffingBoxFriction}
step={1}
onChange={(v) => update("stuffingBoxFriction", v)}
/>
</Row>
<Row label="Molded guide ratio">
<NumberField
value={state.moldedGuideFrictionRatio}
step={0.1}
onChange={(v) => update("moldedGuideFrictionRatio", v)}
/>
</Row>
<Row label="Wheeled guide ratio">
<NumberField
value={state.wheeledGuideFrictionRatio}
step={0.1}
onChange={(v) => update("wheeledGuideFrictionRatio", v)}
/>
</Row>
<Row label="Other guide ratio">
<NumberField
value={state.otherGuideFrictionRatio}
step={0.1}
onChange={(v) => update("otherGuideFrictionRatio", v)}
/>
</Row>
</Fieldset>
</div>
<div className="action-row">
<button type="button" className="btn btn-secondary" onClick={onExportXml}>
Export XML
</button>
<button
type="button"
className="btn btn-primary"
disabled={loading || checks.hasBlockingError}
onClick={onRun}
>
{loading ? "Solving…" : checks.hasBlockingError ? "Fix checks to run" : "▶ Run Solver"}
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,126 @@
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { NumberField } from "../common/NumberField";
type Props = { store: CaseStore };
export function TrajectoryTab({ store }: Props) {
const { state, addSurveyRow, removeSurveyRow, updateSurveyRow, setSurvey } = store;
function loadVertical() {
const depth = state.pumpDepth || 1727;
setSurvey([
{ md: 0, inc: 0, azi: 0 },
{ md: depth, inc: 0, azi: 0 }
]);
}
function loadDeviatedExample() {
const depth = state.pumpDepth || 1727;
setSurvey([
{ md: 0, inc: 0, azi: 0 },
{ md: Math.min(300, depth * 0.17), inc: 0, azi: 0 },
{ md: Math.min(600, depth * 0.35), inc: 0, azi: 0 },
{ md: Math.min(800, depth * 0.46), inc: 12, azi: 45 },
{ md: Math.min(1000, depth * 0.58), inc: 25, azi: 45 },
{ md: Math.min(1200, depth * 0.7), inc: 35, azi: 45 },
{ md: Math.min(1500, depth * 0.87), inc: 35, azi: 45 },
{ md: depth, inc: 35, azi: 45 }
]);
}
function clearAll() {
setSurvey([]);
}
return (
<Fieldset legend="Well Trajectory — Survey Table (MD / Inc / Az)">
<p className="panel-note">
Enter survey stations from surface to TD. Minimum curvature method (API
Bulletin D20) is applied in the solver. First row should be 0 MD, 0 Inc, 0 Az.
</p>
<div className="button-row">
<button type="button" className="btn" onClick={() => addSurveyRow()}>
Add Station
</button>
<button type="button" className="btn" onClick={clearAll}>
Clear All
</button>
<button type="button" className="btn" onClick={loadVertical}>
Load Vertical Default
</button>
<button type="button" className="btn" onClick={loadDeviatedExample}>
Load Deviated Example
</button>
<span className="panel-note" style={{ marginLeft: "auto" }}>
{state.survey.length} station{state.survey.length === 1 ? "" : "s"}
</span>
</div>
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th style={{ width: 40 }}>#</th>
<th>MD</th>
<th>Inclination (°)</th>
<th>Azimuth (°)</th>
<th style={{ width: 60 }}></th>
</tr>
</thead>
<tbody>
{state.survey.map((row, i) => (
<tr key={i}>
<td>{i + 1}</td>
<td>
<NumberField
value={row.md}
onChange={(v) => updateSurveyRow(i, { md: v })}
ariaLabel={`MD ${i + 1}`}
/>
</td>
<td>
<NumberField
value={row.inc}
step={0.1}
min={0}
max={180}
onChange={(v) => updateSurveyRow(i, { inc: v })}
ariaLabel={`Inc ${i + 1}`}
/>
</td>
<td>
<NumberField
value={row.azi}
step={1}
min={0}
max={360}
onChange={(v) => updateSurveyRow(i, { azi: v })}
ariaLabel={`Azi ${i + 1}`}
/>
</td>
<td>
<button
type="button"
className="btn btn-danger"
onClick={() => removeSurveyRow(i)}
aria-label={`Remove station ${i + 1}`}
>
</button>
</td>
</tr>
))}
{state.survey.length === 0 && (
<tr>
<td colSpan={5} className="empty-row">
No survey stations. Add rows or load a preset.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Fieldset>
);
}

View File

@@ -0,0 +1,81 @@
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { Row } from "../common/Row";
import { NumberField } from "../common/NumberField";
import { TextField } from "../common/TextField";
import { SelectField } from "../common/SelectField";
type Props = { store: CaseStore };
export function WellTab({ store }: Props) {
const { state, update } = store;
return (
<div className="tab-grid two">
<Fieldset legend="Well Identification">
<Row label="Well Name / UWI" htmlFor="wellName">
<TextField
id="wellName"
value={state.wellName}
onChange={(v) => update("wellName", v)}
/>
</Row>
<Row label="Company" htmlFor="company">
<TextField
id="company"
value={state.company}
onChange={(v) => update("company", v)}
/>
</Row>
<Row
label="Units Selection"
htmlFor="unitsSelection"
hint="0 or 2 = imperial oilfield; other values treated as SI"
>
<SelectField
id="unitsSelection"
value={state.unitsSelection}
onChange={(v) => update("unitsSelection", v)}
options={[
{ value: 0, label: "0 — legacy imperial (default)" },
{ value: 1, label: "1 — SI" },
{ value: 2, label: "2 — imperial oilfield" }
]}
/>
</Row>
</Fieldset>
<Fieldset legend="Depths / Tubing">
<Row
label="Pump Depth"
htmlFor="pumpDepth"
hint={state.unitsSelection === 1 ? "metres" : "feet (imperial)"}
>
<NumberField
id="pumpDepth"
value={state.pumpDepth}
onChange={(v) => update("pumpDepth", v)}
/>
</Row>
<Row
label="Tubing Anchor Location"
htmlFor="tubingAnchor"
hint={state.unitsSelection === 1 ? "metres" : "feet (imperial)"}
>
<NumberField
id="tubingAnchor"
value={state.tubingAnchorLocation}
onChange={(v) => update("tubingAnchorLocation", v)}
/>
</Row>
<Row label="Tubing Nominal Size (in)" htmlFor="tubingSize">
<NumberField
id="tubingSize"
value={state.tubingSize}
step={0.125}
onChange={(v) => update("tubingSize", v)}
/>
</Row>
</Fieldset>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { RawFieldValue } from "../../state/caseModel";
export 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 "";
}
export function describeRawField(value: RawFieldValue): string {
if (value === undefined || value === null) return "(empty)";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}

15
gui-ts/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}

14
gui-ts/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/testSetup.ts"]
}
});