Initial commit: establish deterministic rod-string solver stack.

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

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

View File

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