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:
12
gui-ts/Dockerfile
Normal file
12
gui-ts/Dockerfile
Normal 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
12
gui-ts/index.html
Normal 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
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
28
gui-ts/package.json
Normal 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
240
gui-ts/src/App.test.tsx
Normal 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
1
gui-ts/src/App.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { App } from "./ui/App";
|
||||
86
gui-ts/src/api/client.ts
Normal file
86
gui-ts/src/api/client.ts
Normal 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
10
gui-ts/src/main.tsx
Normal 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>
|
||||
);
|
||||
48
gui-ts/src/state/__tests__/engineeringChecks.test.ts
Normal file
48
gui-ts/src/state/__tests__/engineeringChecks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
111
gui-ts/src/state/__tests__/xmlExport.test.ts
Normal file
111
gui-ts/src/state/__tests__/xmlExport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
179
gui-ts/src/state/caseModel.ts
Normal file
179
gui-ts/src/state/caseModel.ts
Normal 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];
|
||||
83
gui-ts/src/state/engineeringChecks.ts
Normal file
83
gui-ts/src/state/engineeringChecks.ts
Normal 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")
|
||||
};
|
||||
}
|
||||
|
||||
73
gui-ts/src/state/trajectoryMetrics.ts
Normal file
73
gui-ts/src/state/trajectoryMetrics.ts
Normal 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 };
|
||||
}
|
||||
|
||||
130
gui-ts/src/state/useCaseStore.ts
Normal file
130
gui-ts/src/state/useCaseStore.ts
Normal 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
|
||||
]
|
||||
);
|
||||
}
|
||||
160
gui-ts/src/state/xmlExport.ts
Normal file
160
gui-ts/src/state/xmlExport.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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(":");
|
||||
}
|
||||
145
gui-ts/src/state/xmlImport.ts
Normal file
145
gui-ts/src/state/xmlImport.ts
Normal 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
383
gui-ts/src/styles.css
Normal 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
48
gui-ts/src/testSetup.ts
Normal 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
145
gui-ts/src/types.ts
Normal 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
262
gui-ts/src/ui/App.tsx
Normal 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
50
gui-ts/src/ui/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
gui-ts/src/ui/common/CheckboxField.tsx
Normal file
22
gui-ts/src/ui/common/CheckboxField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
gui-ts/src/ui/common/Fieldset.tsx
Normal file
13
gui-ts/src/ui/common/Fieldset.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
gui-ts/src/ui/common/NumberField.tsx
Normal file
56
gui-ts/src/ui/common/NumberField.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
gui-ts/src/ui/common/RadioGroup.tsx
Normal file
29
gui-ts/src/ui/common/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
gui-ts/src/ui/common/Row.tsx
Normal file
18
gui-ts/src/ui/common/Row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
gui-ts/src/ui/common/SelectField.tsx
Normal file
40
gui-ts/src/ui/common/SelectField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
gui-ts/src/ui/common/TextField.tsx
Normal file
23
gui-ts/src/ui/common/TextField.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
gui-ts/src/ui/common/UPlotChart.tsx
Normal file
63
gui-ts/src/ui/common/UPlotChart.tsx
Normal 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%" }} />;
|
||||
}
|
||||
342
gui-ts/src/ui/common/Wellbore3DView.tsx
Normal file
342
gui-ts/src/ui/common/Wellbore3DView.tsx
Normal 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 (< {(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>
|
||||
);
|
||||
}
|
||||
|
||||
33
gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx
Normal file
33
gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
140
gui-ts/src/ui/tabs/AdvancedTab.tsx
Normal file
140
gui-ts/src/ui/tabs/AdvancedTab.tsx
Normal 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)}…`;
|
||||
}
|
||||
57
gui-ts/src/ui/tabs/FluidTab.tsx
Normal file
57
gui-ts/src/ui/tabs/FluidTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
gui-ts/src/ui/tabs/KinematicsTab.tsx
Normal file
119
gui-ts/src/ui/tabs/KinematicsTab.tsx
Normal 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 -0.6,13200 ..."
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
gui-ts/src/ui/tabs/PumpTab.tsx
Normal file
70
gui-ts/src/ui/tabs/PumpTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
536
gui-ts/src/ui/tabs/ResultsTab.tsx
Normal file
536
gui-ts/src/ui/tabs/ResultsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
gui-ts/src/ui/tabs/RodStringTab.tsx
Normal file
130
gui-ts/src/ui/tabs/RodStringTab.tsx
Normal 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 > 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>
|
||||
);
|
||||
}
|
||||
171
gui-ts/src/ui/tabs/SolverTab.tsx
Normal file
171
gui-ts/src/ui/tabs/SolverTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
gui-ts/src/ui/tabs/TrajectoryTab.tsx
Normal file
126
gui-ts/src/ui/tabs/TrajectoryTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
gui-ts/src/ui/tabs/WellTab.tsx
Normal file
81
gui-ts/src/ui/tabs/WellTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
gui-ts/src/ui/tabs/rawFieldHelpers.ts
Normal file
21
gui-ts/src/ui/tabs/rawFieldHelpers.ts
Normal 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
15
gui-ts/tsconfig.json
Normal 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
14
gui-ts/vite.config.ts
Normal 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"]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user