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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user