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

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules/
**/node_modules/
dist/
**/dist/
build/
coverage/
.vite/
solver-c/solver_main
solver-c/solver_fea_main
solver-c/test_solver
*.o
*.out
.DS_Store
*.log
.env
.env.*
.vscode/
.idea/

69
AGENTS.md Normal file
View File

@@ -0,0 +1,69 @@
# Agent rulebook — Rods Cursor solver
This file is the **canonical** instruction set for AI agents working in this repository. Read it before changing code or docs.
## Authoritative docs (read order)
1. **[Agents/MATH_SPEC.md](Agents/MATH_SPEC.md)** — Equations, discretization, BCs, paper citations (`references/papers/README.md`). **Do not invent math** not grounded there or in a cited calibration test.
2. **[Agents/COMPUTE_PLAN.md](Agents/COMPUTE_PLAN.md)** — Living handoff: repo map, current gaps vs `MATH_SPEC.md`, deferred work, jump table.
3. **[README.md](README.md)** — Human/agent quickstart, API examples, validation commands.
## Product intent
Build a **transparent, deterministic** rod-string / dynamometer solver stack: **C** is the authoritative numerics engine (predictive + diagnostic, FDM + FEA); **Node (`solver-api`)** is XML/IO, orchestration, card QA, and API versioning; **TypeScript (`gui-ts`)** is workflow and visualization (not the primary solver).
Outcomes:
- Physically explainable outputs, not black-box curves.
- **Dynamometer cards** (polished + downhole) as the central engineering surface.
- Preserve **inspectability** of imported XML (`rawFields`, `unsupportedFields`, `verbose`).
- Multi-model workflows: run **FDM** and **FEA**, compare, surface discrepancies and assumptions.
## Architecture (non-negotiable)
| Layer | Responsibility |
|-------------|----------------|
| `solver-c/` | All heavy numerics: damped wave FDM, bar FEM, trajectory preprocess, diagnostic transfer, gravity/buoyancy, deviated-well friction proxies, iterative diagnostic FEA when enabled. |
| `solver-api/` | HTTP, `parseCaseXml`, SI normalization, JSON → C stdin, surface-card QA, response shaping (`schemaVersion`, `comparison`, `verbose`, `pumpMovement`). |
| `gui-ts/` | UI: import, plots, 3D survey when implemented, warnings. **No** primary PDE solve in TS except temporary shims during migration (avoid new TS numerics). |
Same C binaries power Docker, local `make test`, and CI.
## Guardrails
- **Determinism:** identical inputs → identical outputs (regression tests and golden hashes depend on this).
- **API fields:** do not remove `verbose`, `pumpMovement`, or `comparison` without bumping `schemaVersion` and documenting migration.
- **No undocumented “magic” multipliers.** If a simplification is not in `MATH_SPEC.md`, tag it `heuristic` in code comments and add a ticket in `COMPUTE_PLAN.md` or a calibration test in `docs/engineering/validation.md`.
- **XML:** never fabricate field names or values not read from a real case file. Unknown fields stay in `rawFields` / `unsupportedFields`.
- **Units:** internal solve uses **SI** (`docs/engineering/units.md`). Convert at the API/parser boundary.
- **Papers:** grounding lives in `references/papers/README.md` (Romero & Almeida; Everitt & Jennings; SPE-173970 Araujo et al.; Eisner et al.).
## GUI (product)
The GUI is first-class for end users: continuous visibility of survey and cards, 3D trajectory when implemented, side-by-side solver comparison. Agent work may still touch **compute-only** paths without GUI in a given PR; do not add new numerics in the GUI.
## When you change the solver
1. Update `MATH_SPEC.md` if equations or BCs change.
2. Update `COMPUTE_PLAN.md` gap table / status.
3. Run `make test` and golden regression (`solver-api` tests include golden hash where configured).
4. If C CLI or JSON contract changes, update `README.md`, `docker-compose.yml` gcc lines, and root `Dockerfile` compile commands.
## Repository map (compute)
| Path | Role |
|------|------|
| `solver-c/src/solver.c` | FDM predictive + shared numerics |
| `solver-c/src/solver_fea.c` | FEA predictive + diagnostic iteration (when applicable) |
| `solver-c/src/solver_diagnostic.c` | Diagnostic FDM (surface card BC) |
| `solver-c/src/trajectory.c` | Survey → curvature / inclination on rod grid (SPE-173970 style) |
| `solver-c/src/json_stdin.c` | Minimal JSON stdin parser for C drivers |
| `solver-api/src/solverClient.js` | Build SI JSON, `execFile` C with stdin |
| `solver-api/src/xmlParser.js` | Parse case + SI conversion |
| `solver-api/src/app.js` | Routes, comparison payload, `schemaVersion` |
| `data/cases/base-case.xml` | Canonical regression case |
| `data/golden/` | Golden response fingerprints |
---
*Merged from legacy `Agents/Instructions.md`. Product vision details are maintained in `README.md`.*

213
Agents/COMPUTE_PLAN.md Normal file
View File

@@ -0,0 +1,213 @@
# Compute handoff — Rods Cursor
**Owner:** Agent team
**Status:** Active
**Last updated:** 2026-04-16
**Canonical math:** [MATH_SPEC.md](MATH_SPEC.md)
**Agent rules:** [AGENTS.md](../AGENTS.md) at repo root
**Reference bibliography:** [references/papers/README.md](../references/papers/README.md)
---
## 1. Session context
Math-first rod-string / dynamometer stack:
- **C (`solver-c`)** — Authoritative FDM + FEA + diagnostic transfer + trajectory preprocess. **JSON on stdin** to `solver_main` / `solver_fea_main` (`schemaVersion: 2`).
- **Node (`solver-api`)** — XML → SI model, surface-card QA, orchestration, `comparison` / `verbose` / `pumpMovement`, `schemaVersion` in responses.
- **GUI (`gui-ts`)** — Workflow / plots, XML round-trip editor, engineering checks, and 3D wellbore visualization.
User goals: SROD-like transparency, measured card → downhole + pump movement, FDM vs FEA comparison, no invented physics.
---
## 2. Repository map
| Path | Role |
|------|------|
| `solver-c/src/solver.c` | FDM predictive + gravity/buoyancy + variable \(EA\rho\) + side-load Coulomb |
| `solver-c/src/solver_fea.c` | FEA predictive + Rayleigh damping + diagnostic bisection (Eisner-style) |
| `solver-c/src/solver_diagnostic.c` | Diagnostic FDM from surface card |
| `solver-c/src/trajectory.c` | Survey → curvature / inclination on rod grid |
| `solver-c/src/json_stdin.c` | stdin JSON → `SolverInputs` |
| `solver-api/src/xmlParser.js` | Parse + **SI** normalization |
| `solver-api/src/solverClient.js` | Build JSON, run C |
| `solver-api/src/cardQa.js` | Surface card validation |
| `solver-api/src/app.js` | HTTP, extended `comparison`, `validate-card` |
| `data/cases/base-case.xml` | Canonical case |
| `data/golden/default.solve.sha256` | Golden fingerprint for `/solve/default` |
---
## 3. Status vs MATH_SPEC (gap table)
| Item | MATH_SPEC § | Code status |
|------|-------------|-------------|
| Variable \(EA\) FDM | §13 | **Done** — per-node `area_m2`, `modulus_pa`, `density_kg_m3` |
| Gravity + buoyancy | §1 | **Done** — distributed \(f = \rho g A - \rho_f g A\) (simplified annulus = rod area) |
| Explicit FD + CFL | §3 | **Done**`verbose.numerics` |
| 3D survey / curvature | §5 | **Partial**`trajectory.c` resamples MD/inc/azi; full Araujo Eqs. 1418 system = future refinement |
| Lukasiewicz lateral PDE | §4 | **Partial** — side load from \(T,\kappa,\phi\) proxy + Coulomb (`heuristic` tag in verbose) |
| Valve state machine | §7 | **Partial** — simplified phase + `gasInterference` flag from load plateau heuristic |
| Diagnostic FDM in C | §7 | **Done**`solver_diagnostic.c` |
| Diagnostic FEA + iteration | §67 | **Done** — bisection on bottom load in `solver_fea.c` when `workflow=diagnostic` |
| Rayleigh \(\alpha,\beta\) from XML damping | §2 | **Done** — tied to rod length + damping factors |
| Multi-material taper | §1 | **Done**`RodTypeArray` / modulus arrays in JSON |
| Fourier analytical baseline | §3 | **Deferred** |
| Full tubetube contact (Eisner) | §6 | **Deferred** |
---
## 4. API (quick reference)
```http
POST /solve
Content-Type: application/json
{
"xml": "<INPRoot>...</INPRoot>",
"solverModel": "fdm|fea|both",
"workflow": "predictive|diagnostic",
"surfaceCard": { "position": [], "load": [], "time": [] },
"options": { "schemaVersion": 2 }
}
```
- `GET /solve/default` — predictive only; supports `?solverModel=`.
- `POST /solve/validate-card` — QA only (no solve).
- Response: `schemaVersion`, `units: "SI"`, `solver`, `solvers?`, `comparison` (extended), `pumpMovement?`, `verbose`, `runMetadata`.
---
## 5. Runbook
```bash
make test
./solver-c/test_solver
cd solver-api && npm test
# Docker
docker compose up -d --build
make smoke
```
`docker-compose.yml` gcc lines must list all `solver-c/src/*.c` objects used by `main.c` / `main_fea.c`.
---
## 6. Deferred (explicit)
| Topic | Notes |
|-------|--------|
| Full **Costa / SPE-173970** 3D wave with \(K_1,K_2\) lateral load PDE in C | Currently curvature + side-load **proxy**; implement from paper, not ad hoc |
| **Torsion** | Not in current four-paper backbone |
| **Pumping-unit kinematics** (Svinos tables, crank motion from `PumpingUnitID`) | Harmonic default; unit geometry unused |
| **Inverse calibration** | Fit damping / friction to measured downhole card |
| **Fourier** analytical diagnostic | Optional `comparison.fourier` |
| **GUI** 3D survey + layout | **Partial done** — Results tab includes 3D projected wellbore + rod/pump overlay + DLS contour; not a full 3D engine (no camera controls / mesh terrain yet) |
| **GUI diagnostic workflow** | Tabbed UI ships predictive solve end-to-end; diagnostic requires surface-card upload path in Kinematics (calls `POST /solve/validate-card` + `POST /solve` with `workflow=diagnostic`) — not wired in this pass |
| **GUI Pump / Fluid / Kinematics first-class mapping** | Tabs render editable fields but rely on `rawFields` round-trip rather than dedicated serializer logic; audit once solver-api adds explicit fields for `PumpFillageOption`, pumping-unit kinematics, etc. |
| **GUI fatigue / API RP 11BR table** | Backend does not emit a fatigue payload yet; surface in Results tab when `solver.fatigue` exists |
---
## 6.1 GUI checks/visualization shipped
- Fixed engineering gate in Solver tab:
- run blocked when `|PumpDepth - sum(TaperLengthArray)| > 15 m`.
- survey MD monotonicity and minimum station-count checks.
- Fixed DLS bad-section threshold:
- warnings + 3D contour use `15 deg/100` as the "bad section" limit.
- Results tab now shows:
- uPlot dynacard overlays,
- 3D projected wellbore with rod gradient and pump marker,
- interactive 3D view controls (rotate, pan, zoom, perspective/orthographic toggle, reset),
- highlighted bad-DLS segments,
- trajectory analytics table with row↔3D segment cross-highlight,
- side-load overlay mode (when `solver.profiles.sideLoadProfile` is available),
- pump-placement diagnostics panel + navigation actions,
- export actions (3D SVG, 3D PNG, summary JSON).
- keyboard-accessible trajectory segment selection (`Enter`/`Space`) and clear-highlight control.
- Kinematics/Solver workflow now supports diagnostic execution end-to-end in GUI:
- measured card paste input (`position,load` rows),
- `POST /solve/validate-card` QA call from Kinematics tab,
- diagnostic solve payload wiring (`workflow=diagnostic`, `surfaceCard`),
- solve options include profile generation for visualization overlays.
---
## 7. Jump table
| Task | Start |
|------|--------|
| FDM numerics | `solver-c/src/solver.c` |
| FEA + diagnostic bisection | `solver-c/src/solver_fea.c` |
| Diagnostic FDM | `solver-c/src/solver_diagnostic.c` |
| Trajectory | `solver-c/src/trajectory.c` |
| JSON CLI | `solver-c/src/json_stdin.c`, `main.c`, `main_fea.c` |
| SI + XML | `solver-api/src/xmlParser.js` |
| Run C | `solver-api/src/solverClient.js` |
| Card QA | `solver-api/src/cardQa.js` |
| HTTP | `solver-api/src/app.js` |
| Tests | `solver-api/tests/api.test.js`, `solver-c/tests/test_solver.c` |
---
## 8. Quality Program (next execution block)
This section is the execution plan for the next pass, optimized for "feature-rich but solid".
### 8.1 Priority 1 — Correctness gates first
- Build a field-sensitivity harness (per mapped input field, ±1% perturbation).
- Enforce invariants in solver outputs:
- finite values everywhere,
- gas fraction bounds,
- physically valid valve-state transitions,
- profile array length consistency.
- Expand deterministic goldens beyond base case.
**Acceptance gate:** no merge if sensitivity/invariant tests fail.
### 8.2 Priority 2 — Solver fidelity
- Reduce remaining heuristic terms toward equation-backed Costa/Araujo + Lukasiewicz formulations.
- Harden valve/gas state model transitions with explicit edge-case fixtures.
- Add stability fallback behavior and numerical-health reporting.
**Acceptance gate:** documented equation coverage increases; no stability regressions.
### 8.3 Priority 3 — Validation depth
- Add cross-model agreement matrix (FDM vs FEA) across canonical cases.
- Add synthetic/analytical sanity cases where expected trends are known.
- Track residual drift trends over commits.
**Acceptance gate:** tolerance matrix passes for all canonical cases.
### 8.4 Priority 4 — Contract hardening
- Keep `schemaVersion: 2` additive contract stable by default.
- Enforce option-gated heavy payloads (`profiles`, `diagnostics`, `fourier`).
- Add traceability metadata endpoint/payload support for GUI and audits.
**Acceptance gate:** backward-compat tests pass on default endpoints.
### 8.5 Priority 5 — CI/release readiness
- Add sanitizer runs (ASan/UBSan) for C paths.
- Add runtime/performance budgets on representative cases.
- Enforce quality artifact generation in CI (comparison summaries + drift reports).
**Acceptance gate:** CI quality lane green.
### 8.6 Multi-agent execution split
- Agent A: solver numerics/fidelity.
- Agent B: GUI integration + import/export + rendering.
- Agent C: verification harness + CI guardrails + independent audit.
---
*Extend §3 and §8 together whenever milestones ship.*

133
Agents/MATH_SPEC.md Normal file
View File

@@ -0,0 +1,133 @@
# Math specification — literature backbone
**Status:** Living document. Every implemented term should trace to a citation below or to an explicit `heuristic` calibration note in code + `docs/engineering/validation.md`.
**Primary references:** see `references/papers/README.md` for citation details and source links.
- Romero, A., and Almeida, A. R. (2014). *A Numerical Sucker-Rod Pumping Analysis Tool*. SPE Artificial Lift Conference - Latin America and the Caribbean. [SPE-169395-MS](https://doi.org/10.2118/169395-MS)
- Everitt, T. A., and Jennings, J. W. (1992). *An Improved Finite-Difference Calculation of Downhole Dynamometer Cards for Sucker-Rod Pumps*. SPE Production Engineering. [SPE-18189-PA](https://doi.org/10.2118/18189-PA)
- Araujo, O., et al. (SPE-173970). *3D Rod String Dynamics in Deviated Wells* (minimum-curvature and coupled dynamics reference used for trajectory coupling).
- Eisner, B., Langbauer, C., and Fruhwirth, R. (2022). *A finite element approach for dynamic sucker-rod diagnostics* (Newmark + Rayleigh + diagnostic iteration basis).
- Lukasiewicz, H. (as summarized in Eisner et al.) coupled axial/lateral force balance for deviated rod strings.
---
## 1. Gibbs one-dimensional damped wave (vertical / uniform rod)
**Romero & Almeida — Eq. (2)** (viscous damped wave, constant \(A,E,\rho\)):
\[
\frac{\partial^2 u}{\partial t^2} = a^2 \frac{\partial^2 u}{\partial x^2} - \varsigma \frac{\partial u}{\partial t}
\]
with \(a^2 = E/\rho\) (wave speed) and \(\varsigma\) viscous damping coefficient per unit mass (paper uses lumped fluid damping narrative).
**Variable cross-section (Everitt & Jennings — Eq. 2 form)** after multiplying through by \(\rho A\); axial stiffness gradient:
\[
\frac{\partial}{\partial x}\left( EA \frac{\partial u}{\partial x} \right) = \rho A \frac{\partial^2 u}{\partial t^2} + c \rho A \frac{\partial u}{\partial t} - f_{\text{body}}
\]
where \(f_{\text{body}}\) collects distributed **weight and buoyancy** along the rod (gravity along tangent; buoyancy from fluid — **Lukasiewicz** axial force balance in Eisner Eq. (2) discussion / Lukasiewicz coupled model referenced in Eisner).
**Implementation note:** Code uses discrete \(E_i, A_i, \rho_i\) per node or segment, harmonic or measured surface \(u(0,t)\), and bottom boundary coupling (pump / valve / spring-damper per roadmap).
---
## 2. Damping conventions
- **Gibbs dimensionless damping** \(\nu\): related to decay rate; Romero cites \(\varsigma\) proportional to velocity; Eisner Eq. (1) uses \(\frac{\pi a \nu}{2L}\frac{\partial u}{\partial t}\) form for vertical damped wave.
- **Rayleigh damping (FEA, Eisner):** \(\mathbf{C} = \alpha \mathbf{M} + \beta \mathbf{K}\) for bar elements.
- **XML factors:** `UpStrokeDampingFactor`, `DownStrokeDampingFactor`, `NonDimensionalFluidDamping` modulate effective \(\gamma\) or \(\alpha,\beta\) per phase (mapped in solver; see code comments).
---
## 3. Explicit finite-difference stencil (diagnostic / deviated extension)
**Everitt & Jennings — Eq. (3)** (conceptual explicit FD recursion transferring displacements downhole from surface card). The paper gives a five-point relation in \((i,j)\) space (space index \(i\), time \(j\)) with coefficients involving \(a\), \(c\), \(\Delta t\), \(\Delta x\), and \(EA\).
**Variable \(EA\):** harmonic mean or segment fluxes:
\[
\text{flux}_{i+\frac12} = E_{i+\frac12} A_{i+\frac12} \left( u_{i+1} - u_i \right)
\]
Laplacian-like term formed from \(\partial/\partial x (EA \partial u/\partial x)\) discretization (matches current diagnostic JS prior to C port).
**CFL:** explicit wave requires \(\Delta t \le \text{CFL} \cdot \Delta x / a_{\max}\) with \(a_{\max} = \max \sqrt{E/\rho}\). Code clamps effective CFL (see `verbose.numerics.cflEffective`).
---
## 4. Deviated wells — coupled axial / lateral (reference)
**Lukasiewicz (via Eisner Eqs. (2)(3))** — force balance along rod tangent and normal; includes \(\rho g A \cos\phi\), viscosity term \(\nu\), Coulomb \(\mu N\), curvature \(R\), and lateral equilibrium.
**Current implementation:** distributed **side load** \(N_i\) and inclination-aware **Coulomb friction** \(F_{f,i} = \mu_{\text{eff}} N_i \operatorname{sgn}(v_i)\) are computed per node and injected into FDM/diagnostic/FEA updates. API exposes `profiles.sideLoadProfile` and `profiles.frictionProfile` when `options.enableProfiles=true`.
---
## 5. Three-dimensional trajectory (SPE-173970)
**Araujo et al. — Eqs. (5)(24):** minimum-curvature method between survey stations: unit tangent, curvature angle \(\gamma_i\), radius \(r_{c,i}\), binormal, center of curvature, position propagation \(R(s_{i+1})\), and interpolation of any MD \(s\) within a segment.
**Implementation:** `solver-c/src/trajectory.c` uses minimum-curvature style tangent interpolation and exports node-wise \(\kappa(s)\), inclination, and azimuth on the rod grid for side-load/friction coupling and `trajectory3D` output.
---
## 6. Dynamic 1D bar FEM (Eisner et al.)
- **Bar stiffness / mass:** consistent element \(K_e\), \(M_e\) for linear shape functions.
- **Newmark-β** (\(\beta=\frac14\), \(\gamma=\frac12\)) for transient axial dynamics.
- **Bottom BC:** spring-damper + friction; diagnostic mode adds **iterative plunger load** adjustment so that computed top reaction matches measured polished load (Eisner Fig. 4 principle — restart / bisection per time step).
---
## 7. Boundary conditions
| Mode | Surface | Bottom |
|------|---------|--------|
| **Predictive** | Harmonic \(u(0,t)\) (Romero **Eq. (4)** style crank motion) or measured surrogate | Pump: spring-damper + friction + valve-state pressure balance; gas fraction inferred from chamber state |
| **Diagnostic** | Measured \(u(0,t)\) and \(F(0,t)\) from dynamometer card (Everitt) | Same pump BC; FEA adjusts unknown plunger load and reports valve/gas timeline |
**Surface card QA (Eisner narrative):** ≥ 75 samples recommended for Fourier-style tools; we enforce minimum samples and cycle checks in API (`POST /solve/validate-card`).
---
## 8. Outputs (API / solver)
- Polished and downhole **cards** \((x, F)\).
- **Pump movement:** plunger position series, velocity, stroke, period.
- **Optional profiles:** `stressProfile`, `sideLoadProfile`, `trajectory3D` (when `schemaVersion >= 2`).
- **Comparison:** FDM vs FEA peak deltas + point-wise residuals + optional Fourier analytical baseline (`comparison.fourier` when enabled).
---
## 9. Symbols (SI internal)
| Symbol | Unit | Meaning |
|--------|------|---------|
| \(u\) | m | Axial displacement |
| \(F\) | N | Axial force (tension positive) |
| \(E\) | Pa | Youngs modulus |
| \(A\) | m² | Cross-sectional area |
| \(\rho\) | kg/m³ | Rod density |
| \(\phi\) | rad | Inclination from vertical |
| \(\kappa\) | 1/m | Path curvature |
---
## 10. Implementation quality rules (for next execution)
- Every newly introduced solver term must be tagged as one of:
- `equation-backed` (with paper/equation reference),
- `heuristic` (with rationale + planned replacement).
- No silent heuristic drift: if a coefficient changes, update validation fixtures and notes.
- Any new field wired into equations must be reflected in `docs/engineering/field-traceability.md`.
- Any new coupled term must include at least:
- one unit-scale numerical test,
- one integration case assertion,
- one regression guard.
---
*End of math spec backbone. Extend with equation numbers from PDFs as features land.*

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:20-alpine AS api-deps
WORKDIR /app/solver-api
COPY solver-api/package.json solver-api/package-lock.json* ./
RUN npm install
FROM node:20-alpine AS gui-deps
WORKDIR /app/gui-ts
COPY gui-ts/package.json gui-ts/package-lock.json* ./
RUN npm install
FROM alpine:3.20 AS solver-build
RUN apk add --no-cache build-base
WORKDIR /app
COPY solver-c ./solver-c
RUN SRCS="./solver-c/src/solver_common.c ./solver-c/src/json_stdin.c ./solver-c/src/trajectory.c ./solver-c/src/solver_diagnostic.c ./solver-c/src/solver.c ./solver-c/src/solver_fea.c ./solver-c/src/solver_fourier.c" \
&& gcc -std=c99 -I./solver-c/include $$SRCS ./solver-c/src/main.c -lm -o ./solver-c/solver_main \
&& gcc -std=c99 -I./solver-c/include $$SRCS ./solver-c/src/main_fea.c -lm -o ./solver-c/solver_fea_main \
&& gcc -std=c99 -I./solver-c/include $$SRCS ./solver-c/tests/test_solver.c -lm -o ./solver-c/test_solver \
&& ./solver-c/test_solver
FROM node:20-alpine AS ci
WORKDIR /app
COPY --from=solver-build /app/solver-c ./solver-c
COPY --from=api-deps /app/solver-api/node_modules ./solver-api/node_modules
COPY --from=gui-deps /app/gui-ts/node_modules ./gui-ts/node_modules
COPY solver-api ./solver-api
COPY gui-ts ./gui-ts
COPY data ./data
CMD ["sh", "-c", "cd /app/solver-api && npm test && cd /app/gui-ts && npm test"]

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
SHELL := /bin/bash
.PHONY: run down logs test smoke
run:
docker compose up --build
down:
docker compose down
logs:
docker compose logs -f
test:
cd solver-api && npm test
cd gui-ts && npm test
./solver-c/test_solver
smoke:
@echo "Checking API health..."
@for i in {1..30}; do \
if curl --fail --silent http://localhost:4400/health > /dev/null; then \
echo "API healthy"; \
break; \
fi; \
sleep 1; \
if [ $$i -eq 30 ]; then \
echo "API did not become healthy in time"; \
exit 1; \
fi; \
done
@echo "Checking solve endpoint with base case..."
@curl --fail --silent http://localhost:4400/solve/default > /dev/null && echo "Solve endpoint healthy"

238
README.md Normal file
View File

@@ -0,0 +1,238 @@
# Rods Cursor — Rod-string solver (math-first)
Deterministic **C** numerical core (FDM + FEA), **Node** API for XML and orchestration, **TypeScript** GUI for workflow. See **[AGENTS.md](AGENTS.md)** for agent rules and **[Agents/MATH_SPEC.md](Agents/MATH_SPEC.md)** for equations and paper citations.
## Repository layout
| Directory | Purpose |
|-----------|---------|
| `solver-c/` | C: damped-wave FDM, dynamic bar FEM, diagnostic transfer, trajectory preprocess, **JSON stdin** drivers |
| `solver-api/` | Express: `POST /solve`, `GET /solve/default`, `POST /case/parse`, `POST /solve/validate-card`, XML → SI |
| `gui-ts/` | Vite + React tabbed case editor + uPlot dynacards |
| `data/cases/` | `base-case.xml` and regression inputs |
| `data/golden/` | Golden SHA-256 fingerprint for default solve regression |
| `Agents/` | `MATH_SPEC.md`, `COMPUTE_PLAN.md` (handoff) |
| `docs/engineering/` | Architecture, schema, units, validation |
| `references/papers/` | Literature citations and access notes for the solver math backbone |
## Vision
Build a transparent, deterministic rod-string and wellbore mechanics platform that is field-usable and research-grade.
- Physically explainable pumping-system simulations, not black-box outputs.
- Dynamometer cards as the primary interpretation surface.
- Inspectability of imported case data and solver assumptions.
- Multi-model validation (FDM vs FEA) with clear comparison metadata.
## Prerequisites
- **Local:** `gcc`, `make`, Node 20+, `npm`
- **Docker:** Docker Engine + Compose plugin
## Run locally
### Build and run C solver (stdin JSON)
The API spawns `solver-c/solver_main` and pipes **one JSON object** on stdin (`schemaVersion: 2`). Legacy 9-argument CLI is **removed**.
```bash
gcc -std=c99 -I./solver-c/include \
./solver-c/src/solver_common.c \
./solver-c/src/json_stdin.c \
./solver-c/src/trajectory.c \
./solver-c/src/solver_diagnostic.c \
./solver-c/src/solver.c \
./solver-c/src/solver_fea.c \
./solver-c/src/solver_fourier.c \
./solver-c/src/main.c -lm -o ./solver-c/solver_main
gcc -std=c99 -I./solver-c/include \
./solver-c/src/solver_common.c \
./solver-c/src/json_stdin.c \
./solver-c/src/trajectory.c \
./solver-c/src/solver_diagnostic.c \
./solver-c/src/solver.c \
./solver-c/src/solver_fea.c \
./solver-c/src/solver_fourier.c \
./solver-c/src/main_fea.c -lm -o ./solver-c/solver_fea_main
./solver-c/test_solver
```
### API + GUI
```bash
cd solver-api && npm install && npm run dev
cd gui-ts && npm install && npm run dev
```
- API: `http://localhost:4400/health`
- GUI: `http://localhost:5173`
## Run with Docker
```bash
make run # or: docker compose up --build
make smoke # requires API on 4400
make down
```
## Validation
```bash
make test # solver-api vitest + gui-ts tests + solver-c test_solver
./solver-c/test_solver
```
Golden hash: `solver-api` tests assert `/solve/default` body matches `data/golden/default.solve.sha256` (after normalizing `generatedAt`).
## API examples
### Predictive (default base case)
```bash
curl -sS "http://localhost:4400/solve/default?solverModel=both" | jq '.schemaVersion, .runMetadata, .comparison | keys'
```
### Predictive (POST XML)
```bash
curl -sS -X POST http://localhost:4400/solve \
-H "Content-Type: application/json" \
-d "{\"xml\": $(jq -Rs . < data/cases/base-case.xml), \"solverModel\": \"fdm\"}" | jq '.solver.pointCount, .schemaVersion'
```
### Extended physics outputs (profiles/diagnostics/fourier)
```bash
curl -sS -X POST http://localhost:4400/solve \
-H "Content-Type: application/json" \
-d "{\"xml\": $(jq -Rs . < data/cases/base-case.xml), \"solverModel\": \"both\", \"options\": {\"enableProfiles\": true, \"enableDiagnosticsDetail\": true, \"enableFourierBaseline\": true, \"fourierHarmonics\": 10}}" \
| jq '.solver.profiles.nodeCount, .solver.diagnostics.valveStates[0], .comparison.fourier.harmonics'
```
### Diagnostic (measured surface card)
Build `surfaceCard` from a predictive run or field data:
```bash
CARD=$(curl -sS "http://localhost:4400/solve/default?solverModel=fdm")
# Then POST xml + workflow + surfaceCard (see solver-api/tests/api.test.js)
```
### Surface card QA only
```bash
curl -sS -X POST http://localhost:4400/solve/validate-card \
-H "Content-Type: application/json" \
-d '{"surfaceCard":{"position":[0,1,2],"load":[10,11,12]}}' | jq .
```
### Parse XML only (no solve)
```bash
curl -sS -X POST http://localhost:4400/case/parse \
-H "Content-Type: application/json" \
-d "{\"xml\": $(jq -Rs . < data/cases/base-case.xml)}" | jq '.schemaVersion, .model.wellName, (.unsupportedFields | length)'
```
Returns `{ model, rawFields, unsupportedFields, warnings, schemaVersion: 2 }` — same shape as `GET /case/default`, but for an uploaded XML string. Used by the GUI to hydrate its case editor from an import.
## GUI tabbed case editor
`gui-ts` renders a tabbed UI backed by a single `CaseState` that round-trips to/from the XML document. On first load it pulls `GET /case/default` and populates every tab; editing any field mutates `CaseState`, which is serialized back into `<INPRoot><Case>…</Case></INPRoot>` on solve or export. Untouched XML fields (fatigue, IPR blocks, pumping-unit catalog keys, etc.) are preserved verbatim in the output.
| Tab | Contents |
|-----|----------|
| Well | Well name, company, units selection, pump depth, tubing anchor / size |
| Trajectory | Editable MD / Inc / Az survey table with vertical + deviated presets |
| Kinematics | Pumping speed (SPM), speed option, unit ID, upstroke/downstroke percentages |
| Rod String | Taper table (diameter, length, modulus, rod type) with base-case preset |
| Pump | Plunger diameter, friction, intake pressure, fillage option + percent |
| Fluid | Water cut, water SG, oil API, tubing gradient |
| Solver | `solverModel` (fdm/fea/both), workflow selector, damping + friction knobs, engineering checks gate, **Run Solver** |
| Results | KPI banner, uPlot dynacard (polished + downhole, with FEA overlay when applicable), FDM↔FEA comparison, warnings, unsupported-field list, 3D wellbore/rod/pump view with DLS or side-load contour, trajectory analytics table, pump diagnostics, export tools |
| Advanced / XML | File upload + paste box (POST `/case/parse`), export current state as XML, raw-field inspector |
### Built-in engineering checks
- **Pump depth vs total rod length:** solver run is blocked if the absolute mismatch exceeds **15 m**.
- **Trajectory integrity:** requires at least 2 stations and strictly increasing measured depth.
- **DLS warning threshold:** if max dogleg severity exceeds **15 deg/100**, the UI surfaces a warning.
These are fixed guardrails (not user configurable) to keep behavior deterministic and consistent across sessions.
### 3D wellbore visualization
The Results tab includes a 3D projected wellbore panel:
- Tubing trajectory polyline colored by DLS contour (green/yellow/red).
- **Bad sections** highlighted in red for DLS >= **15 deg/100**.
- Rod string overlay drawn from surface to rod total length with depth color gradient.
- Pump marker placed along trajectory at `PumpDepth`.
- Interactive controls: drag to rotate, `Shift+drag` to pan, mouse wheel / buttons to zoom, projection toggle (perspective/orthographic), and reset view.
- Overlay modes:
- `DLS`: uses fixed bad-section threshold `15 deg/100`
- `Side-load risk`: colors by normalized side-load profile returned from solver outputs (`options.enableProfiles=true`)
### Trajectory analytics + cross-highlight
- Results includes a per-segment trajectory table (`MD start/end`, `ΔMD`, `DLS`, severity).
- Clicking a segment row highlights the corresponding 3D trajectory segment.
- Clicking a segment in 3D highlights the corresponding table row.
- Filter toggle supports "bad sections only".
- Keyboard accessibility: segment rows are focusable and selectable with `Enter` / `Space`.
### Pump placement diagnostics
Results tab now reports:
- nearest survey station to pump depth,
- pump-to-station `ΔMD`,
- survey-end to pump `ΔMD`,
- rod-total to pump `Δ`,
- tubing-anchor to pump `Δ`,
with quick navigation buttons back to Well / Trajectory / Rod tabs for correction.
### Visualization artifact export
Results tab export buttons:
- 3D wellbore **SVG**
- 3D wellbore **PNG**
- run/check summary **JSON**
These are generated client-side from the rendered SVG and current run/check state.
### Diagnostic workflow (GUI wired)
- Kinematics tab accepts measured surface card points as `position,load` rows.
- `Validate Surface Card` calls `POST /solve/validate-card`.
- Solver tab `workflow=diagnostic` now sends `surfaceCard` to `POST /solve`.
- Solve calls include `options.enableProfiles=true` so side-load overlays can be rendered.
## Solver modes
| `solverModel` | Behavior |
|----------------|----------|
| `fdm` | Finite-difference damped wave + variable rod + trajectory friction |
| `fea` | 1D bar FEM + Newmark + Rayleigh damping |
| `both` | Runs FDM + FEA; returns `solvers` and extended `comparison` |
| `workflow` | Behavior |
|-------------|----------|
| `predictive` | Harmonic surface motion (unless overridden later) |
| `diagnostic` | Surface card BC; FDM in C; FEA uses bisection on pump load to match measured top load |
## Optional CI image
```bash
docker build -t rods-ci .
docker run --rm rods-ci
```
## Where to read next
1. [AGENTS.md](AGENTS.md)
2. [Agents/MATH_SPEC.md](Agents/MATH_SPEC.md)
3. [Agents/COMPUTE_PLAN.md](Agents/COMPUTE_PLAN.md)
4. [docs/engineering/units.md](docs/engineering/units.md)
5. [docs/engineering/validation.md](docs/engineering/validation.md)

170
data/cases/base-case.xml Normal file
View File

@@ -0,0 +1,170 @@
<?xml version="1.0"?>
<INPRoot>
<Case>
<ActualCounterbalance>0</ActualCounterbalance>
<AddMoldedGuideWeightChecked>1</AddMoldedGuideWeightChecked>
<AdjustedStroke>0</AdjustedStroke>
<Analyst>Conner </Analyst>
<AzimuthFromNorthArray>0:14.4:108.5:124.4:115.2:96.4:80.6:184.5:83.7:102.8:316:146.7:159.3:275.7:266.1:170.1:289.5:293.3:297.2:288.8:303.5:32.7:70.6:75.6:76.5:80.2:81.6:80.2:79.8:80.7:80.7:80.4:79.7:78.9:79.2:79.2:78.6:77.4:76.5:76.4:77.3:77.4:77.8:78.2:78:78.3:78.2:78.2:78.9:79.8:80.2:80.2:80.3:80.3:80.6:79.8:79.1:78.2:78.2:79:79.3:79.3:79.3:79.3:79.3:79.7:78.8:79.5:79.2:79:77.9:76.7:76.4:76.9:76:76.7:77.3:76.9:76.7:77.4:76.9:77.1:76.7:77.1:76.7:76.4:76:76.4:76.4:76.2:76:75.7:75:75.5:76.4:76.4:75.5:76.4:75.3:76:74.8:75.3:75.1:76.4:76.7:77.8:77.3:77.3:77.6:77.4:77.3:78.1:78.7:77.9:77.3:77.3:77.3:76.7:76:77.3:77.6:78.3:79.2:79.2:78.8:78.8:78.7:77.8:79:78.5:78.5:78.3:77.9:77.8:77.8:77.3:77.8:77.9:76.5:76.4:76.4:76.7:76.5:76.9:76.4:76:76.9:76.9:78.5:78.5:77.3:79:79.5:78.8:80.2:80.1:81.5:80.1:79.7:80.1:79.5:80.6:80.8:80.6:80.8:81.5:81.6:81.6:80.4:80.6:79.7:79.5:78.8:78.1:76.7:75:76.2:75.3:76.2:76.2:75.1:76.2:74.8:75.3:75.5:75.3:75.5:74.6:73.9:72.3</AzimuthFromNorthArray>
<BitWalkRateArr>0</BitWalkRateArr>
<BuildupRateArr>0</BuildupRateArr>
<BuildupRateForAutoDesign>0</BuildupRateForAutoDesign>
<BulkModulus>2</BulkModulus>
<BuoyantWeightAdjustment>0</BuoyantWeightAdjustment>
<CaseName BaseCaseName="Base Case">Base Case</CaseName>
<CasingHeadPressure>0</CasingHeadPressure>
<CBalOption>0</CBalOption>
<Comments />
<Company>Veren</Company>
<CounterWeight>0</CounterWeight>
<CounterWeightInertia>0</CounterWeightInertia>
<CrankHole>1 - 367.3 (cm)</CrankHole>
<CrankID />
<CycleKey>0</CycleKey>
<DateCreated>4/28/2025</DateCreated>
<DecimalDelimiter>.</DecimalDelimiter>
<DesignModeIndex>0</DesignModeIndex>
<DeviationDataCount>190</DeviationDataCount>
<DeviationDateSurvey>4/28/2025</DeviationDateSurvey>
<DownStrokeDampingFactor>0.15</DownStrokeDampingFactor>
<DropOffRateForAutoDesign>0</DropOffRateForAutoDesign>
<ElectricCost>10</ElectricCost>
<ExactFillage>0</ExactFillage>
<ExistingStructureUnbalance>8580</ExistingStructureUnbalance>
<FlowlineHeaderElevation>0</FlowlineHeaderElevation>
<FlowlineInternalDiameter>0</FlowlineInternalDiameter>
<FlowlineLength>0</FlowlineLength>
<FlowlinePressure>2275</FlowlinePressure>
<FluidLevelOilGravity>43</FluidLevelOilGravity>
<FluidPropertyOilGravity>0</FluidPropertyOilGravity>
<GasEngine>0</GasEngine>
<GasEngineHP>0</GasEngineHP>
<GasEngineSpeed>0</GasEngineSpeed>
<HydralicStroke>0</HydralicStroke>
<HydralicStructureRating>0</HydralicStructureRating>
<ImprovingChecked>1</ImprovingChecked>
<InclinationFromVerticalArray>0:0.3:0.7:0.7:0.7:0.8:1:0:0.1:0.8:0.4:1.4:0.5:1.1:0.8:0.7:0.2:0.5:0.8:0.8:0.8:1.1:3.3:5.8:8.1:10.5:12.8:15.6:17.9:21.5:25.2:29:31.9:33.7:35.1:36.4:37.8:40.7:43.6:46.8:48.9:50.4:51.7:53:56.1:60.2:62.6:62.8:64.8:68.1:71.6:73.1:74.7:75.4:77.4:80:82.5:84.5:85.2:87:87.9:88.6:89.52:89.8:89.8:90:90.3:90:90:89.5:89.9:90:89.7:89.4:89.9:90:90.1:90.6:90.6:90.4:90.5:90.4:90.4:90.2:90.2:90.5:90.6:90.1:89.8:89.7:90.3:90.3:90.2:90.7:91.6:91.9:91.6:90.4:90.4:89.6:89.9:89.4:89.3:89.5:89.4:89.9:90.1:90:90.6:90.3:90.1:90.9:91.5:91.6:91.4:90.8:90.7:90.1:90.1:90.1:90.2:90.8:91.1:91.3:91.2:91.1:91:90.8:90:89.8:89.4:89.3:88.9:89.2:89.4:90.4:90.2:90:90.8:91:90:90.1:91.2:90.8:90.5:90.7:90.2:90.2:90.4:90.6:90.1:89.8:90.4:90.5:90.3:90.5:91.4:91:90.9:90.6:90.9:91.1:91.6:91.9:90.7:89.8:89.5:89.6:89.2:88.3:88.8:88.8:89.6:91.1:91.8:92:91.5:91.1:90.6:90.6:90.8:90.1:89.9:89.2:88.9:89.9:89.7:90.7:90.8:90.8</InclinationFromVerticalArray>
<InitialInclinationAngle>0</InitialInclinationAngle>
<IncludeSinkerBar>0</IncludeSinkerBar>
<IncompleteFillageOption>2</IncompleteFillageOption>
<IsOnDaulSpeedVariation>0</IsOnDaulSpeedVariation>
<KickOffDepth>0</KickOffDepth>
<LeadDirection>0</LeadDirection>
<MaximumAirPressure>0</MaximumAirPressure>
<MaximumRodDiameter>0</MaximumRodDiameter>
<MaxMoldedGuide>10</MaxMoldedGuide>
<MaxSideLoadForBaseRod>50</MaxSideLoadForBaseRod>
<MaxSideLoadForMoldedGuide>40</MaxSideLoadForMoldedGuide>
<MaxSideLoadForWheeledGuide>200</MaxSideLoadForWheeledGuide>
<MeasuredDepthArray>0:211.25:302.71:347.65:439.95:533.59:629.78:658.7:754.99:851.37:880.27:976.66:1005.51:1034.41:1082.54:1178.69:1207.53:1303.89:1361.65:1390.31:1399.97:1409.6:1419.27:1428.93:1438.55:1448.17:1457.82:1467.46:1477.11:1486.74:1496.39:1506.08:1515.65:1525.27:1534.9:1544.5:1554.13:1563.77:1573.36:1582.99:1592.55:1602.16:1611.79:1621.4:1631.01:1640.63:1650.24:1659.87:1669.5:1679.14:1688.78:1698.45:1708.04:1717.66:1727.29:1736.93:1746.55:1756.16:1765.78:1775.44:1782:1789.92:1797:1799.16:1808.29:1817.68:1826.74:1835.79:1845.06:1854.32:1863.35:1872.6:1881.88:1890.82:1900.16:1909.46:1919.16:1928.51:1937.17:1946.58:1955.93:1965.54:1974.49:1983.44:1992.94:2002.25:2011.28:2020.31:2029.42:2038.8:2047.95:2057.18:2066.19:2075.62:2084.57:2093.47:2102.88:2112.2:2121.16:2130.42:2139.38:2148.68:2158.13:2167.47:2177.11:2186.72:2196.38:2206:2215.63:2225.21:2234.82:2244.44:2254.09:2263.69:2273.32:2282.94:2292.57:2302.14:2311.78:2321.39:2331.02:2340.63:2350.31:2359.99:2369.61:2379.29:2388.88:2398.49:2408.07:2417.68:2427.33:2437:2446.63:2456.33:2465.9:2475.6:2485.23:2494.81:2504.44:2514.07:2523.67:2533.29:2542.92:2552.6:2562.27:2571.92:2581.55:2591.16:2600.81:2610.45:2620.06:2629.69:2639.29:2648.89:2658.49:2668.16:2677.78:2687.37:2697.01:2706.66:2716.28:2725.91:2735.53:2745.15:2754.77:2764.46:2774.08:2783.64:2793.26:2802.88:2812.51:2822.01:2831.66:2841.39:2851.03:2860.66:2870.25:2879.84:2889.5:2899.12:2908.71:2918.34:2928:2937.63:2947.25:2956.81:2966.43:2976.06:2985.67:2995</MeasuredDepthArray>
<MeasuredDepthInterval>0</MeasuredDepthInterval>
<MinimalRodDiameter>0</MinimalRodDiameter>
<MinimumAirPressure>0</MinimumAirPressure>
<MinMoldedGuide>3</MinMoldedGuide>
<MoldedGuideFrictionRatio>1.5</MoldedGuideFrictionRatio>
<MoldedGuideType>Norris PPS-Standard</MoldedGuideType>
<MotorID />
<MotorKey>0</MotorKey>
<MotorSheaveDiameter>0</MotorSheaveDiameter>
<MotorSheaveInertia>0</MotorSheaveInertia>
<NominalBeltSlip>1</NominalBeltSlip>
<NonDimensionalFluidDamping>1.5</NonDimensionalFluidDamping>
<NumberOfGroove>0</NumberOfGroove>
<NumberOfSections>0</NumberOfSections>
<OtherGuideFrictionRatio>2</OtherGuideFrictionRatio>
<PercentPumpFillage>0</PercentPumpFillage>
<PercentageDownstrokeTime>50</PercentageDownstrokeTime>
<PercentageUpstrokeTime>50</PercentageUpstrokeTime>
<PerforationBottom>0</PerforationBottom>
<PerforationTop>0</PerforationTop>
<PolishedRodDiameter>0</PolishedRodDiameter>
<PowerLineFrequency>60</PowerLineFrequency>
<PumpCapacity>0</PumpCapacity>
<PumpCBubblePointPressure>0</PumpCBubblePointPressure>
<PumpCFluidViscosity>0</PumpCFluidViscosity>
<PumpCGasSpecGravity>0</PumpCGasSpecGravity>
<PumpCGasVentingEfficiency>0</PumpCGasVentingEfficiency>
<PumpCOilGravity>0</PumpCOilGravity>
<PumpCOilProductionRate>0</PumpCOilProductionRate>
<PumpCProducingGOR>0</PumpCProducingGOR>
<PumpCPumpMechEfficiency>0</PumpCPumpMechEfficiency>
<PumpCPumpPlungerClearance>0</PumpCPumpPlungerClearance>
<PumpCPumpPlungerLength>0</PumpCPumpPlungerLength>
<PumpCPumpTemperature>0</PumpCPumpTemperature>
<PumpCWaterProductionRate>0</PumpCWaterProductionRate>
<PumpCWaterSpecGravity>0</PumpCWaterSpecGravity>
<PumpDepth>1727</PumpDepth>
<PumpDiameter>31.75</PumpDiameter>
<PumpEfficiency>80</PumpEfficiency>
<PumpFillageOption>1</PumpFillageOption>
<PumpFriction>200</PumpFriction>
<PumpingSpeed>5</PumpingSpeed>
<PumpingSpeedOption>1</PumpingSpeedOption>
<PumpingUnitID>HG320-256-144</PumpingUnitID>
<PumpingUnitKey>1802</PumpingUnitKey>
<PumpIntakePressure>100</PumpIntakePressure>
<PumpLoadAdjustment>0</PumpLoadAdjustment>
<PumpLoadCoefficient>5</PumpLoadCoefficient>
<ReducerSheaveDiameter>0</ReducerSheaveDiameter>
<ReducerSheaveInertia>0</ReducerSheaveInertia>
<ReportOption>0</ReportOption>
<RodFrictionCoefficient>0.2</RodFrictionCoefficient>
<RodGuideTypeArray>M:M:M:N:M:::::</RodGuideTypeArray>
<RodGuideWeightArray>0:0:0:0:0:0:0:0:0:0</RodGuideWeightArray>
<RodLengthForFiberglass>11.43</RodLengthForFiberglass>
<RodLengthForSteel>7.62</RodLengthForSteel>
<RodLoading>0</RodLoading>
<RodTypeArray>3:3:2:3:3:0:0:0:0:0</RodTypeArray>
<RotationKey>-1</RotationKey>
<Runtime>24</Runtime>
<SelectHydralicUnit>0</SelectHydralicUnit>
<SeparatorPressure>275.79</SeparatorPressure>
<ServiceFactor>0.8</ServiceFactor>
<ShallowWell>0</ShallowWell>
<SheaveOption>0</SheaveOption>
<SinkerBarDiameter>0</SinkerBarDiameter>
<SinkerBarLength>0</SinkerBarLength>
<SinkerBarType />
<SpeedVariationKey>1</SpeedVariationKey>
<SRODRecommendNemaDMotor>1</SRODRecommendNemaDMotor>
<StuffingBoxFriction>100</StuffingBoxFriction>
<Taper />
<TaperCount>5</TaperCount>
<TaperDesignOption>1</TaperDesignOption>
<TaperDiameterArray>22.225:19.05:38.1:19.05:19.05:0:0:0:0:0</TaperDiameterArray>
<TaperGuidesCountArray>-1:-1:-1:-1:-1:-1:-1:-1:-1:-1:-1:-1:-1:-1:-1:-1</TaperGuidesCountArray>
<TaperLengthArray>86:86:10:36:9:0:0:0:0:0</TaperLengthArray>
<TaperLumpsArray>0:0:0:0:0:0:0:0:0:0</TaperLumpsArray>
<TaperModulusArray>30.5:30.5:30.5:30.5:30.5:0:0:0:0:0</TaperModulusArray>
<TaperMTSArray>792897.055:792897.055:620528.13:792897.055:792897.055:0:0:0:0:0</TaperMTSArray>
<TaperWeightArray>2.224:1.634:6:1.634:1.634:0:0:0:0:0:0:0:0:0:0:0</TaperWeightArray>
<TotalDepartureOfTarget>0</TotalDepartureOfTarget>
<TrueFluidDepth>0</TrueFluidDepth>
<TubingAnchorLocation>1361.3</TubingAnchorLocation>
<TubingGradient>9.989</TubingGradient>
<TubingSize>3</TubingSize>
<TVD>0</TVD>
<UnitsSelection>2</UnitsSelection>
<UpStrokeDampingFactor>0.05</UpStrokeDampingFactor>
<Version>9.0.0</Version>
<VerticalDepthArr>0</VerticalDepthArr>
<ViewOption>3</ViewOption>
<WaterCut>73</WaterCut>
<WaterSpecGravity>1.096</WaterSpecGravity>
<WellDeviationType>1</WellDeviationType>
<WellName>191/01-27-007-09W2/00</WellName>
<WheeledGuideFrictionRatio>0.1</WheeledGuideFrictionRatio>
<DesiredMaxSPM>1</DesiredMaxSPM>
<DesiredMinSPM>1</DesiredMinSPM>
<PumpingSpeedByVFD>0</PumpingSpeedByVFD>
<GradientBelowPump>0</GradientBelowPump>
<ReservoirDepth>0</ReservoirDepth>
<StaticReservoirPressure>0</StaticReservoirPressure>
<OilProductionRate>0</OilProductionRate>
<WaterProductionRate>0</WaterProductionRate>
<BubblePoint>0</BubblePoint>
<DesiredResProdPress>0</DesiredResProdPress>
<IPRInputMode>Vogel</IPRInputMode>
<VogalPointList>0:0:0</VogalPointList>
</Case>
</INPRoot>

View File

@@ -0,0 +1 @@
d433dd1061c9f26679507fac42299d97d6d9c0b446651eeaa6ac03529e424fa0

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
solver-api:
build:
context: ./solver-api
dockerfile: Dockerfile
container_name: rods-solver-api
working_dir: /app
ports:
- "4400:4400"
volumes:
- ./solver-api/src:/app/src
- ./data:/data
- ./solver-c:/solver-c
command: sh -c "SRCS='/solver-c/src/solver_common.c /solver-c/src/json_stdin.c /solver-c/src/trajectory.c /solver-c/src/solver_diagnostic.c /solver-c/src/solver.c /solver-c/src/solver_fea.c /solver-c/src/solver_fourier.c' && gcc -std=c99 -I/solver-c/include $$SRCS /solver-c/src/main.c -lm -o /solver-c/solver_main && gcc -std=c99 -I/solver-c/include $$SRCS /solver-c/src/main_fea.c -lm -o /solver-c/solver_fea_main && npm run dev"
gui-ts:
build:
context: ./gui-ts
dockerfile: Dockerfile
container_name: rods-gui-ts
working_dir: /app
ports:
- "5173:5173"
environment:
- VITE_API_BASE=http://localhost:4400
volumes:
- ./gui-ts:/app
- /app/node_modules
command: npm run dev -- --host 0.0.0.0 --port 5173
depends_on:
- solver-api

View File

@@ -0,0 +1,70 @@
# Architecture and requirement traceability
Owner: Rods Project Team
Status: Active
Last Updated: 2026-04-16
Version: 0.2.0
## Module boundaries
- **`solver-c`:** Authoritative numerics — FDM, FEA, diagnostic FDM, trajectory preprocess, distributed side-load/friction coupling, valve/gas dynamic diagnostics, optional Fourier analytical baseline, gravity/buoyancy, variable \(E(x), A(x), \rho(x)\), JSON **stdin** contract (`schemaVersion: 2`). No HTTP/XML.
- **`solver-api`:** Local HTTP service — parse XML, convert to **SI**, surface-card QA, spawn C binaries with JSON pipe, attach `verbose`, `comparison`, `pumpMovement`, `schemaVersion`.
- **`gui-ts`:** Workflow and visualization (tabbed case editor, XML round-trip, dynacards, engineering checks, 3D wellbore preview); not the primary PDE solver.
- **`data/cases`:** Canonical inputs and regression cases.
- **`data/golden`:** Fingerprints for deterministic API regression.
- **`references/papers/README.md`:** Citation index for external literature used by `Agents/MATH_SPEC.md`.
## C driver contract
Executables read **one JSON object from stdin** (UTF-8). Required top-level keys are produced by `solver-api/src/solverClient.js` — see that file for the exact shape (`workflow`, `model`, optional `surfaceCard`, optional `options`).
`options` supports:
- `enableProfiles` — includes trajectory + side-load + friction profile payloads.
- `enableDiagnosticsDetail` — includes valve-state and chamber/gas time-series.
- `enableFourierBaseline` — computes analytical Fourier baseline in C.
- `fourierHarmonics` — harmonic count for baseline reconstruction.
Legacy argv-based invocation has been **removed** to avoid drift between CLI and API.
## MVP requirement traceability
| Requirement | Module | Notes |
|-------------|--------|--------|
| Real C solver path | `solver-c`, `solver-api` | JSON pipe; no mock-only solve |
| XML inspectability | `solver-api`, `gui-ts` | `rawFields`, `unsupportedFields` |
| Output cards primary | `solver-c`, `gui-ts` | Polished + downhole series |
| Determinism | `solver-c`, `solver-api` | Golden hash; no randomness |
| Warnings / numerics | `solver-c`, `solver-api` | `warnings`, `verbose.numerics`, CFL |
| 3D survey visibility | `gui-ts` | Results tab includes projected 3D wellbore with DLS/side-load overlay modes + rod/pump overlays + interactive camera controls |
## System flow
1. GUI or client sends XML (+ options) to API.
2. API parses to canonical **SI** model + preserves unknown fields.
3. Optional: `POST /solve/validate-card` runs QA only.
4. API builds JSON and runs `solver_main` / `solver_fea_main` with stdin.
5. C returns JSON stdout; API merges `parsed`, `comparison`, `runMetadata`.
6. GUI renders cards, survey, inspection panels.
## Execution guidelines for upcoming hardening
- Keep defaults stable; grow contract additively behind options.
- Treat C as numerical source-of-truth; API orchestrates and validates.
- Require test evidence for every new equation term:
- unit-level numeric sanity,
- integration-level behavior,
- deterministic regression impact.
- Pair every new feature with:
- one "happy path" case,
- one edge/stress case,
- one regression gate entry.
## GUI guardrails and thresholds
- Pump-depth consistency gate: solver run is blocked when `abs(PumpDepth - totalRodLength) > 15 m`.
- DLS bad-section threshold: `15 deg/100` used both for warning logic and 3D contour highlighting.
- Thresholds are intentionally fixed (not user-configurable) to keep deterministic behavior.
- Diagnostic GUI path is now wired:
- measured card input + QA call in Kinematics tab,
- `workflow=diagnostic` run from Solver tab with `surfaceCard` payload.
- Results now include trajectory segment analytics and artifact export actions (3D SVG/PNG, summary JSON).

View File

@@ -0,0 +1,62 @@
# Case schema and import contract
Owner: Rods Project Team
Status: Active
Last Updated: 2026-04-16
Version: 0.2.0
## Canonical input
- Root: `INPRoot/Case`
- Example: `data/cases/base-case.xml`
## Parsed fields used by solver (SI after `xmlParser`)
### Core (required)
- `PumpingSpeed` — SPM
- `PumpDepth` — MD to pump (case units → m)
- `MeasuredDepthArray`, `InclinationFromVerticalArray` — trajectory (`:` separated)
- `AzimuthFromNorthArray` — optional for vertical; required for full 3D curvature in API validation
### Friction / contact
- `RodFrictionCoefficient` — Coulomb \(\mu\) baseline
- `StuffingBoxFriction`, `PumpFriction` — case force units → N
- `MoldedGuideFrictionRatio`, `WheeledGuideFrictionRatio`, `OtherGuideFrictionRatio` — scale effective \(\mu\) along string (`heuristic` blend)
### Rod string / materials
- `TaperDiameterArray`, `TaperLengthArray`, `TaperModulusArray`, `TaperWeightArray`, `TaperMTSArray`
- `RodTypeArray` — maps to steel vs fiberglass density/modulus defaults when modulus not set
- `TubingAnchorLocation`
- `SinkerBarDiameter`, `SinkerBarLength`
- `RodGuideTypeArray`, `RodGuideWeightArray` — metadata / future contact
### Damping
- `UpStrokeDampingFactor`, `DownStrokeDampingFactor`
- `NonDimensionalFluidDamping` — Gibbs-style dimensionless damping input
### Pump / fluid (valve + buoyancy helpers)
- `PumpDiameter`, `PumpIntakePressure`, `PumpFillageOption`, `PercentPumpFillage`
- `WaterCut`, `WaterSpecGravity`, `FluidLevelOilGravity`, `TubingGradient`, `TubingSize`
- `PercentageUpstrokeTime`, `PercentageDownstrokeTime`
- `PumpingUnitID`, `PumpingSpeedOption` — metadata for future kinematics
### Units
- `UnitsSelection` — drives internal conversion (see `units.md`)
## Unknown / unsupported field policy
- Parse immediate `Case` children into `rawFields`.
- Fields outside the MVP subset used for **solver** remain in `unsupportedFields` but are **preserved** in API responses.
- Do not silently drop imported tags.
## Validation rules
- Required for parse: `PumpingSpeed`, `PumpDepth`, `MeasuredDepthArray`, `InclinationFromVerticalArray`.
- Trajectory arrays must have equal lengths after parse.
- Numeric parse failures throw explicit errors.

View File

@@ -0,0 +1,84 @@
# Field Traceability Matrix
Last Updated: 2026-04-17
## Scope
This matrix tracks how `data/cases/base-case.xml` fields flow through the solver:
1. XML parse (`solver-api/src/xmlParser.js`)
2. SI conversion (`normalizeToSi`)
3. C payload (`solver-api/src/solverClient.js`)
4. Equation/physics use (`solver-c/src/*.c`)
## Coverage summary (base-case)
- Total XML fields: **165**
- Fields present in MVP schema and present in base-case: **41**
- Unsupported/preserved fields: **124**
## A) Fully used in solver equations
| XML field | Parsed + SI | Payload key(s) | Equation use |
|---|---|---|---|
| `PumpingSpeed` | yes | `pumping_speed` | time step / period in FDM/FEA |
| `PumpDepth` | yes | `pump_depth` | rod length, hydrostatic terms |
| `TubingAnchorLocation` | yes | `tubing_anchor_location` | rod length |
| `RodFrictionCoefficient` | yes | `rod_friction_coefficient` | Coulomb friction scaling |
| `StuffingBoxFriction` | yes | `stuffing_box_friction` | polished-load boundary friction |
| `PumpFriction` | yes | `pump_friction` | downhole boundary friction |
| `MeasuredDepthArray` | yes | `survey_md_m` | trajectory/node mapping |
| `InclinationFromVerticalArray` | yes | `survey_inc_rad` | trajectory + gravity projection |
| `AzimuthFromNorthArray` | yes | `survey_azi_rad` | trajectory curvature |
| `TaperDiameterArray` | yes | `area_m2` | axial stiffness/mass terms |
| `TaperLengthArray` | yes | rod-node assembly | rod section distribution |
| `TaperModulusArray` | yes | `modulus_pa` | stiffness terms |
| `RodTypeArray` | yes | density/modulus defaults | mass/stiffness terms |
| `UpStrokeDampingFactor` | yes | `upstroke_damping` | FEA damping calibration |
| `DownStrokeDampingFactor` | yes | `downstroke_damping` | damping (path-level) |
| `NonDimensionalFluidDamping` | yes | `non_dim_damping` | FEA damping factor |
| `MoldedGuideFrictionRatio` | yes | `molded_guide_mu_scale` | friction scaling |
| `WheeledGuideFrictionRatio` | yes | `wheeled_guide_mu_scale` | friction scaling |
| `OtherGuideFrictionRatio` | yes | `other_guide_mu_scale` | friction scaling |
| `PumpDiameter` | yes | `pump_diameter_m` | chamber pressure / valve logic |
| `PumpIntakePressure` | yes | `pump_intake_pressure_pa` | hydro/chamber pressure |
| `PumpFillageOption` | yes | `pump_fillage_option` | gas-interference logic |
| `PercentPumpFillage` | yes | `percent_pump_fillage` | gas-fraction logic |
| `WaterCut` + `WaterSpecGravity` + `FluidLevelOilGravity` | yes | `fluid_density_kg_m3` | buoyancy/hydrostatic terms |
| `TaperWeightArray` | yes | `weight_n_per_m` | node buoyed-weight in side-load |
| `TaperMTSArray` | yes | `mts_n` | friction scaling term |
| `RodGuideWeightArray` | yes | `guide_weight_n_per_m` | side-load normal-force term |
| `SinkerBarDiameter` + `SinkerBarLength` | yes | `sinker_bar_*` | added sinker side-load contribution |
## B) Parsed/payloaded but not yet active in governing equations
| XML field | Current state | Planned use |
|---|---|---|
| `TubingSize` | parsed + converted to `tubing_id_m` + payloaded | annular buoyancy/contact refinements |
| `TubingGradient` | parsed + converted to `tubingGradientPaM`; not payloaded to C math | hydraulic pressure model |
| `PercentageUpstrokeTime` | payloaded (`percent_upstroke_time`) | non-harmonic kinematics timing |
| `PercentageDownstrokeTime` | payloaded (`percent_downstroke_time`) | non-harmonic kinematics timing |
| `PumpingUnitID` | parsed only | pumping-unit geometry/kinematics tables |
| `PumpingSpeedOption` | parsed only | drive/kinematics modes |
| `RodGuideTypeArray` | parsed only | type-specific contact/friction law |
## C) Unsupported but preserved in API
All non-MVP fields from base-case remain in `rawFields` and are listed in `unsupportedFields`; they are not currently used in C equations.
Representative examples:
- `ActualCounterbalance`
- `BulkModulus`
- `CasingHeadPressure`
- `PumpEfficiency`
- `IPRInputMode`
- `VogalPointList`
- `SeparatorPressure`
- `PowerLineFrequency`
## D) Contract notes
- Heavy output blocks (`profiles`, `diagnostics`, `fourierBaseline`) are emitted by C.
- Fourier baseline is computed when `options.enableFourierBaseline=true`.
- Default deterministic golden hash updated after physics/field-wiring changes.

46
docs/engineering/units.md Normal file
View File

@@ -0,0 +1,46 @@
# Units and internal conventions
Owner: Rods Project Team
Status: Active
Last Updated: 2026-04-16
## Principle
All values passed from `solver-api` to `solver-c` **JSON** are **SI**:
| Quantity | SI unit |
|----------|---------|
| Length | m |
| Force | N |
| Pressure | Pa |
| Mass density | kg/m³ |
| Time | s |
| Angle | rad (stored in JSON as rad; XML may be degrees) |
| SPM | 1/min (dimensionally s⁻¹ scale; kept as `pumping_speed` scalar per stroke period \(T = 60/\text{SPM}\) s) |
## `UnitsSelection` (XML)
`base-case.xml` uses `<UnitsSelection>2</UnitsSelection>` (example). Parser maps:
| Code | Assumption in parser |
|------|----------------------|
| `0` / missing | Field units match legacy **oilfield mixed** inchftlbf where applicable (see below) |
| `2` | **Imperial oilfield** — lengths in **ft**, diameters in **in**, moduli in **Mpsi** (×10⁶ psi), forces in **lbf**, pressures in **psi** |
| Other | Treated like `2` with warning in `parsed.warnings` (`heuristic`) |
### Conversion factors (exact)
- `1 in = 0.0254 m`
- `1 ft = 0.3048 m`
- `1 lbf = 4.4482216152605 N`
- `1 psi = 6894.757293168 Pa`
- `1 Mpsi = 6.894757293168e9 Pa`
- `deg → rad`: multiply by \(\pi/180\)
### Fluid density helper
Mixture density from `WaterCut`, `WaterSpecGravity`, `FluidLevelOilGravity` uses simplified API formula for buoyancy; tagged `heuristic` in `model.fluidDensityHeuristic`.
## JSON to C
`SolverInputs` receives only SI. GUI may show field units; API documentation states SI in solve payload.

View File

@@ -0,0 +1,83 @@
# Validation plan
Last Updated: 2026-04-16
## Determinism
- Same XML + same `solverModel` + `workflow` → identical `solver.card` arrays (bitwise for floats at printed precision in C stdout path).
- API responses include `runMetadata.generatedAt`**stripped** before golden hash in tests.
## Golden file
- File: `data/golden/default.solve.sha256`
- Content: SHA-256 of **canonical JSON** string from `GET /solve/default?solverModel=fdm` with `generatedAt` removed and stable key ordering (`stableStringify` in tests).
## Unit coverage
- XML parser: numeric + array + unit conversion.
- `cardQa.js`: min samples, cycle closure, spike rejection.
- C: `test_solver` — determinism, bounds, static equilibrium helper, undamped wave CFL identity, FDM vs FEA peak tolerance.
## Cross-model (FDM vs FEA)
- C unit gate (`solver-c/tests/test_solver.c`): `|maxPolishedLoad_FDM - maxPolishedLoad_FEA| < 7e5 N` on an SI-normalized fixture (interim; tighten when BC parity improves).
## Integration
- `solver-api/tests/api.test.js` — solve routes, diagnostic path, golden hash, `validate-card`, `both` comparison keys.
- `comparison` schema v2 includes:
- `peakLoadDeltas` for Pmax/Pmin and Dmax/Dmin
- `pointwiseResiduals.series[]` (position + polished/downhole residuals)
- `residualSummary` RMS
- `fourier` optional baseline block (`null` unless `options.enableFourierBaseline=true`)
## Extended physics validation
- `options.enableProfiles=true` must return `solver.profiles` with `nodeCount > 0` and finite trajectory/side-load/friction arrays.
- `options.enableDiagnosticsDetail=true` must return `solver.diagnostics` with valve-state booleans and finite chamber/gas series.
- `options.enableFourierBaseline=true` must return non-null `comparison.fourier` and finite residual RMS metrics.
## Priority validation roadmap (15)
### 1) Correctness gates (mandatory)
- Field sensitivity checks: each mapped physics input gets ±1% perturbation and expected directional assertions.
- Invariant checks:
- finite loads/stresses/profiles,
- gas fraction within bounds,
- consistent valve-state transitions,
- no malformed profile/diagnostic arrays.
- Deterministic checks on multi-case golden set.
### 2) Fidelity checks
- Equation-backed vs heuristic term audit must be explicit in docs.
- Stability checks under high-deviation/high-friction cases.
### 3) Cross-model checks
- Case matrix compares FDM vs FEA on:
- peak polished load,
- peak downhole load,
- net stroke,
- residual RMS.
### 4) Contract checks
- Default `/solve` and `/solve/default` responses remain backward compatible.
- Option-gated payload behavior is tested for on/off combinations.
### 5) CI checks
- C sanitizers (ASan/UBSan) lane.
- Performance budget lane (fixed representative cases).
- Artifact lane emits comparison/drift summaries.
## Analytical targets (C tests)
- **Static rod:** uniform bar vertical hang — numerical mean tension trend vs \(\sum \rho g A \Delta x\) (approximate check).
- **Wave CFL:** \(a \Delta t / \Delta x \le 1\) for explicit scheme after clamp.
## GUI
- Smoke: import `base-case.xml`, run solver — manual / Playwright future.

12
gui-ts/Dockerfile Normal file
View File

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { EMPTY_CASE_STATE } from "../caseModel";
import {
DLS_BAD_SECTION_THRESHOLD,
PUMP_ROD_MISMATCH_M,
runEngineeringChecks
} from "../engineeringChecks";
describe("engineering checks fixed thresholds", () => {
it("blocks run when pump depth and rod length mismatch exceeds 15 m", () => {
const state = {
...EMPTY_CASE_STATE,
pumpDepth: 1000,
taper: [
{ diameter: 19.05, length: 980, modulus: 30.5, rodType: 3 }
],
survey: [
{ md: 0, inc: 0, azi: 0 },
{ md: 1000, inc: 0, azi: 0 }
]
};
const checks = runEngineeringChecks(state);
expect(PUMP_ROD_MISMATCH_M).toBe(15);
expect(checks.hasBlockingError).toBe(true);
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(true);
});
it("flags DLS warning above 15 deg/100 threshold", () => {
const state = {
...EMPTY_CASE_STATE,
pumpDepth: 1000,
taper: [
{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }
],
survey: [
{ md: 0, inc: 0, azi: 0 },
{ md: 100, inc: 20, azi: 0 },
{ md: 200, inc: 45, azi: 180 }
]
};
const checks = runEngineeringChecks(state);
expect(DLS_BAD_SECTION_THRESHOLD).toBe(15);
expect(checks.issues.some((i) => i.code === "DLS_HIGH")).toBe(true);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
import type { CaseState } from "./caseModel";
import { computeDoglegSeverityDegPer100 } from "./trajectoryMetrics";
export const PUMP_ROD_MISMATCH_M = 15;
export const DLS_BAD_SECTION_THRESHOLD = 15;
export type EngineeringIssue = {
severity: "warning" | "error";
code: string;
message: string;
};
export type EngineeringChecks = {
issues: EngineeringIssue[];
hasBlockingError: boolean;
};
export function runEngineeringChecks(state: CaseState): EngineeringChecks {
const issues: EngineeringIssue[] = [];
const activeTaper = state.taper.filter((t) => Number.isFinite(t.length) && t.length > 0);
const rodTotal = activeTaper.reduce((acc, t) => acc + t.length, 0);
const pumpDepth = state.pumpDepth;
if (rodTotal > 0 && pumpDepth > 0) {
const diff = Math.abs(pumpDepth - rodTotal);
if (diff > PUMP_ROD_MISMATCH_M) {
issues.push({
severity: "error",
code: "PUMP_ROD_MISMATCH_15M",
message: `Pump depth (${pumpDepth.toFixed(1)}) and total rod length (${rodTotal.toFixed(
1
)}) differ by ${diff.toFixed(1)} m (> ${PUMP_ROD_MISMATCH_M} m limit).`
});
}
}
if (state.survey.length < 2) {
issues.push({
severity: "error",
code: "SURVEY_TOO_SHORT",
message: "Trajectory needs at least 2 survey stations."
});
} else {
let nonMonotonic = false;
let maxDls = 0;
for (let i = 1; i < state.survey.length; i += 1) {
if (state.survey[i].md <= state.survey[i - 1].md) nonMonotonic = true;
maxDls = Math.max(maxDls, computeDoglegSeverityDegPer100(state.survey[i - 1], state.survey[i]));
}
if (nonMonotonic) {
issues.push({
severity: "error",
code: "SURVEY_MD_NON_MONOTONIC",
message: "Measured depth must strictly increase between survey stations."
});
}
if (maxDls > DLS_BAD_SECTION_THRESHOLD) {
issues.push({
severity: "warning",
code: "DLS_HIGH",
message: `High dogleg severity detected (max ${maxDls.toFixed(
2
)} deg/100 > ${DLS_BAD_SECTION_THRESHOLD} deg/100 bad-section threshold).`
});
}
const maxMd = state.survey[state.survey.length - 1].md;
if (pumpDepth > 0 && maxMd > 0 && maxMd < pumpDepth - 10) {
issues.push({
severity: "warning",
code: "SURVEY_BELOW_PUMP_MISSING",
message: `Trajectory ends at MD ${maxMd.toFixed(
1
)}, shallower than pump depth ${pumpDepth.toFixed(1)}.`
});
}
}
return {
issues,
hasBlockingError: issues.some((issue) => issue.severity === "error")
};
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
import { useMemo } from "react";
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { NumberField } from "../common/NumberField";
import { SelectField } from "../common/SelectField";
type Props = { store: CaseStore };
const ROD_TYPE_OPTIONS = [
{ value: 0, label: "0 — steel (generic)" },
{ value: 1, label: "1 — steel (alt. grade)" },
{ value: 2, label: "2 — sinker bar" },
{ value: 3, label: "3 — fiberglass" }
];
export function RodStringTab({ store }: Props) {
const { state, addTaperRow, removeTaperRow, updateTaperRow, setTaper } = store;
const totals = useMemo(() => {
const nonZero = state.taper.filter(
(row) => row.diameter > 0 && row.length > 0
);
const length = nonZero.reduce((acc, r) => acc + r.length, 0);
return { sections: nonZero.length, length };
}, [state.taper]);
function loadDefaultString() {
setTaper([
{ diameter: 22.225, length: 86, modulus: 30.5, rodType: 3 },
{ diameter: 19.05, length: 86, modulus: 30.5, rodType: 3 },
{ diameter: 38.1, length: 10, modulus: 30.5, rodType: 2 },
{ diameter: 19.05, length: 36, modulus: 30.5, rodType: 3 },
{ diameter: 19.05, length: 9, modulus: 30.5, rodType: 3 }
]);
}
return (
<Fieldset legend="Rod String Taper Sections">
<p className="panel-note">
Define taper sections from the top (surface) to the bottom (pump). The
solver treats diameter values &gt; 2 as millimetres and converts to SI.
Modulus 1e8 is treated as Pa; otherwise as Mpsi.
</p>
<div className="button-row">
<button type="button" className="btn" onClick={() => addTaperRow()}>
Add Section
</button>
<button type="button" className="btn" onClick={() => setTaper([])}>
Clear All
</button>
<button type="button" className="btn" onClick={loadDefaultString}>
Load Base-Case String
</button>
<span className="panel-note" style={{ marginLeft: "auto" }}>
{totals.sections} active section{totals.sections === 1 ? "" : "s"} ·
total length {totals.length.toFixed(1)}
</span>
</div>
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th style={{ width: 40 }}>#</th>
<th>Diameter</th>
<th>Length</th>
<th>Modulus</th>
<th>Rod Type</th>
<th style={{ width: 60 }}></th>
</tr>
</thead>
<tbody>
{state.taper.map((row, i) => (
<tr key={i}>
<td>{i + 1}</td>
<td>
<NumberField
value={row.diameter}
onChange={(v) => updateTaperRow(i, { diameter: v })}
ariaLabel={`Taper ${i + 1} diameter`}
/>
</td>
<td>
<NumberField
value={row.length}
onChange={(v) => updateTaperRow(i, { length: v })}
ariaLabel={`Taper ${i + 1} length`}
/>
</td>
<td>
<NumberField
value={row.modulus}
step={0.1}
onChange={(v) => updateTaperRow(i, { modulus: v })}
ariaLabel={`Taper ${i + 1} modulus`}
/>
</td>
<td>
<SelectField
value={row.rodType}
options={ROD_TYPE_OPTIONS}
onChange={(v) => updateTaperRow(i, { rodType: v })}
/>
</td>
<td>
<button
type="button"
className="btn btn-danger"
onClick={() => removeTaperRow(i)}
aria-label={`Remove taper ${i + 1}`}
>
</button>
</td>
</tr>
))}
{state.taper.length === 0 && (
<tr>
<td colSpan={6} className="empty-row">
No taper sections. Add rows or load the base-case string.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Fieldset>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import type { RawFieldValue } from "../../state/caseModel";
export function textOf(value: RawFieldValue): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
if (typeof obj._ === "string") return obj._;
}
return "";
}
export function describeRawField(value: RawFieldValue): string {
if (value === undefined || value === null) return "(empty)";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}

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

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

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

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

View File

@@ -0,0 +1,19 @@
# Paper references
This repository does not store third-party paper PDFs. Keep local copies outside the repo if needed for study. Solver equations and implementation traceability are documented in `Agents/MATH_SPEC.md`.
## Backbone references
1. Romero, A., and Almeida, A. R. (2014). *A Numerical Sucker-Rod Pumping Analysis Tool*. SPE Artificial Lift Conference - Latin America and the Caribbean.
DOI: [10.2118/169395-MS](https://doi.org/10.2118/169395-MS)
2. Everitt, T. A., and Jennings, J. W. (1992). *An Improved Finite-Difference Calculation of Downhole Dynamometer Cards for Sucker-Rod Pumps*. SPE Production Engineering.
DOI: [10.2118/18189-PA](https://doi.org/10.2118/18189-PA)
3. Araujo, O., et al. (SPE-173970). *Three-dimensional rod-string dynamics in deviated wells* (trajectory and curvature coupling reference used by this project).
Source: [SPE paper listing](https://onepetro.org/SPEATCE/proceedings-abstract/15ATCE/All-15ATCE/SPE-173970-MS/183778)
4. Eisner, B., Langbauer, C., and Fruhwirth, R. (2022). *Finite-element-based diagnostics for sucker-rod pumping systems* (Newmark integration and diagnostic load-iteration basis).
Source: keep your licensed copy or institutional access link.
5. Lukasiewicz, H. (referenced through Eisner et al.) coupled axial/lateral formulations for deviated rod-string force balance.

14
solver-api/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache build-base
COPY package.json package-lock.json* ./
RUN npm install
COPY src ./src
EXPOSE 4400
CMD ["npm", "run", "dev"]

2383
solver-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
solver-api/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "solver-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node src/server.js",
"test": "vitest run"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
"supertest": "^7.1.4",
"vitest": "^4.0.2"
}
}

365
solver-api/src/app.js Normal file
View File

@@ -0,0 +1,365 @@
import express from "express";
import cors from "cors";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { parseCaseXml } from "./xmlParser.js";
import { runSolver, deriveTrajectoryFrictionMultiplier } from "./solverClient.js";
import { validateSurfaceCard } from "./cardQa.js";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../");
const DEFAULT_XML = path.join(ROOT, "data/cases/base-case.xml");
const SOLVER_BINARY_FDM = path.join(ROOT, "solver-c/solver_main");
const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_main");
function resolveSolverModel(raw) {
const model = (raw || "fdm").toLowerCase();
if (model !== "fdm" && model !== "fea" && model !== "both") {
throw new Error("solverModel must be 'fdm', 'fea', or 'both'");
}
return model;
}
function resolveWorkflow(raw) {
const workflow = (raw || "predictive").toLowerCase();
if (workflow !== "predictive" && workflow !== "diagnostic") {
throw new Error("workflow must be 'predictive' or 'diagnostic'");
}
return workflow;
}
function residualSeries(aCard, bCard) {
const n = Math.min(aCard.length, bCard.length);
let sumSq = 0;
const series = [];
for (let i = 0; i < n; i += 1) {
const dp = aCard[i].polishedLoad - bCard[i].polishedLoad;
const dd = aCard[i].downholeLoad - bCard[i].downholeLoad;
sumSq += dp * dp + dd * dd;
series.push({
position: aCard[i].position,
polishedLoadResidual: dp,
downholeLoadResidual: dd
});
}
return {
points: n,
rms: Math.sqrt(sumSq / Math.max(2 * n, 1)),
series
};
}
function buildRodStringVerbose(model) {
const lens = model.taperLengthM || [];
const hasTaper = lens.some((v) => Number.isFinite(v) && v > 0);
return {
hasTaper,
taperSectionsUsed: lens.filter((v) => Number.isFinite(v) && v > 0).length
};
}
function createComparisonSummary(fdm, fea) {
const res = residualSeries(fdm.card, fea.card);
const polishedMaxDelta = fea.maxPolishedLoad - fdm.maxPolishedLoad;
const polishedMinDelta = fea.minPolishedLoad - fdm.minPolishedLoad;
const downholeMaxDelta = fea.maxDownholeLoad - fdm.maxDownholeLoad;
const downholeMinDelta = fea.minDownholeLoad - fdm.minDownholeLoad;
return {
schemaVersion: 2,
peakLoadDeltas: {
polishedMaxDelta,
polishedMinDelta,
downholeMaxDelta,
downholeMinDelta
},
/* Backward-compatible flat fields for existing clients. */
polishedMaxDelta,
polishedMinDelta,
downholeMaxDelta,
downholeMinDelta,
residualSummary: {
points: res.points,
rms: res.rms
},
pointwiseResiduals: {
points: res.points,
series: res.series
},
fourier: fdm.fourierBaseline || fea.fourierBaseline || null
};
}
async function runRequestedModels(solverModel, parsedModel, workflow, surfaceCard, options = {}) {
const fdm = await runSolver(SOLVER_BINARY_FDM, parsedModel, workflow, surfaceCard, options);
if (solverModel === "fdm") {
return {
solver: fdm,
solverModel: "fdm"
};
}
const fea = await runSolver(SOLVER_BINARY_FEA, parsedModel, workflow, surfaceCard, options);
if (solverModel === "fea") {
return {
solver: fea,
solverModel: "fea",
solvers: { fdm, fea },
comparison: createComparisonSummary(fdm, fea)
};
}
return {
solver: fdm,
solverModel: "both",
solvers: { fdm, fea },
comparison: createComparisonSummary(fdm, fea)
};
}
async function runRequestedModelsWithWorkflow(solverModel, workflow, parsedModel, surfaceCard, options = {}) {
if (workflow === "predictive") {
const runResults = await runRequestedModels(solverModel, parsedModel, workflow, null, options);
return {
...runResults,
workflow,
verbose: {
workflow: "predictive",
references: [
"Gibbs damped-wave equation for rod-string dynamics",
"Everitt & Jennings finite-difference transfer approach",
"Dynamic finite element bar formulation (Eisner et al.)"
],
boundaryData: {
type: "predicted_surface_motion",
source: "virtual_well_input"
},
numerics: {
schemaVersion: 2,
units: "SI"
},
rodString: buildRodStringVerbose(parsedModel),
trajectoryCoupling: {
frictionMultiplier: deriveTrajectoryFrictionMultiplier(parsedModel)
}
}
};
}
const diag = await runRequestedModels(solverModel, parsedModel, workflow, surfaceCard, options);
return {
...diag,
workflow,
pumpMovement: {
stroke: diag.solver.pumpMovement?.stroke ?? 0,
position: diag.solver.pumpMovement?.position ?? [],
velocity: diag.solver.pumpMovement?.velocity ?? [],
periodSeconds: 60 / (parsedModel.pumpingSpeed || 5)
},
verbose: {
workflow: "diagnostic",
solverModel: diag.solverModel,
references: [
"Gibbs damped-wave equation framework",
"Everitt & Jennings finite-difference diagnostic card computation",
"Eisner et al. FEM diagnostic load iteration (FEA path)"
],
rodString: buildRodStringVerbose(parsedModel),
trajectoryCoupling: {
frictionMultiplier: deriveTrajectoryFrictionMultiplier(parsedModel)
},
numerics: {
schemaVersion: 2,
units: "SI",
variableRodProperties: true
}
}
};
}
function stableStringify(value) {
if (value === undefined) {
return "null";
}
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((v) => stableStringify(v)).join(",")}]`;
}
const keys = Object.keys(value).sort();
const props = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
return `{${props.join(",")}}`;
}
export function buildApp() {
const app = express();
app.use(cors());
app.use(express.json({ limit: "4mb" }));
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/case/default", async (_req, res) => {
try {
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
const parsed = await parseCaseXml(xml);
res.json(parsed);
} catch (error) {
res.status(400).json({ error: String(error.message || error) });
}
});
app.post("/case/parse", async (req, res) => {
try {
const xml = req.body?.xml;
if (typeof xml !== "string" || !xml.trim()) {
return res
.status(400)
.json({ error: "Request body must include xml string", schemaVersion: 2 });
}
const parsed = await parseCaseXml(xml);
return res.json({ ...parsed, schemaVersion: 2 });
} catch (error) {
return res
.status(400)
.json({ error: String(error.message || error), schemaVersion: 2 });
}
});
app.post("/solve/validate-card", (req, res) => {
try {
const surfaceCard = req.body?.surfaceCard;
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
} catch (error) {
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
}
});
app.post("/solve", async (req, res) => {
try {
const xml = req.body?.xml;
if (!xml || typeof xml !== "string") {
return res.status(400).json({ error: "Request body must include xml string" });
}
const solverModel = resolveSolverModel(req.body?.solverModel);
const workflow = resolveWorkflow(req.body?.workflow);
const parsed = await parseCaseXml(xml);
let surfaceCardQa = null;
if (workflow === "diagnostic") {
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
}
const runResults = await runRequestedModelsWithWorkflow(
solverModel,
workflow,
parsed.model,
req.body?.surfaceCard,
req.body?.options || {}
);
const runMetadata = {
deterministic: true,
pointCount: runResults.solver.pointCount,
generatedAt: new Date().toISOString(),
solverModel: runResults.solverModel,
workflow,
schemaVersion: 2,
units: "SI"
};
const fingerprint = crypto
.createHash("sha256")
.update(
stableStringify({
solver: runResults.solver,
solvers: runResults.solvers,
comparison: runResults.comparison,
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement,
verbose: runResults.verbose
})
)
.digest("hex");
return res.json({
schemaVersion: 2,
units: "SI",
parsed,
parseWarnings: parsed.warnings,
surfaceCardQa,
solver: runResults.solver,
solvers: runResults.solvers,
comparison: runResults.comparison,
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement ?? null,
verbose: runResults.verbose,
runMetadata,
fingerprint
});
} catch (error) {
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
}
});
app.get("/solve/default", async (_req, res) => {
try {
const solverModel = resolveSolverModel(_req.query?.solverModel);
const workflow = resolveWorkflow(_req.query?.workflow);
if (workflow === "diagnostic") {
return res.status(400).json({
error: "diagnostic workflow requires POST /solve with surfaceCard data",
schemaVersion: 2
});
}
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
const parsed = await parseCaseXml(xml);
const runResults = await runRequestedModelsWithWorkflow(
solverModel,
workflow,
parsed.model,
null,
_req.body?.options || {}
);
const runMetadata = {
deterministic: true,
pointCount: runResults.solver.pointCount,
generatedAt: new Date().toISOString(),
source: "base-case.xml",
solverModel: runResults.solverModel,
workflow,
schemaVersion: 2,
units: "SI"
};
const fingerprint = crypto
.createHash("sha256")
.update(
stableStringify({
solver: runResults.solver,
solvers: runResults.solvers,
comparison: runResults.comparison,
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement,
verbose: runResults.verbose
})
)
.digest("hex");
return res.json({
schemaVersion: 2,
units: "SI",
parsed,
parseWarnings: parsed.warnings,
solver: runResults.solver,
solvers: runResults.solvers,
comparison: runResults.comparison,
pumpMovement: runResults.pumpMovement ?? runResults.solver?.pumpMovement ?? null,
verbose: runResults.verbose,
runMetadata,
fingerprint
});
} catch (error) {
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
}
});
return app;
}

68
solver-api/src/cardQa.js Normal file
View File

@@ -0,0 +1,68 @@
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
export function validateSurfaceCard(surfaceCard, options = {}) {
const minSamples = options.minSamples ?? 75;
if (!surfaceCard || !Array.isArray(surfaceCard.position) || !Array.isArray(surfaceCard.load)) {
throw new Error("surfaceCard.position and surfaceCard.load arrays are required");
}
if (surfaceCard.position.length !== surfaceCard.load.length) {
throw new Error("surfaceCard.position and surfaceCard.load must have equal length");
}
if (surfaceCard.position.length < minSamples) {
throw new Error(`surfaceCard must contain at least ${minSamples} samples (Eisner guidance)`);
}
if (surfaceCard.time && surfaceCard.time.length !== surfaceCard.position.length) {
throw new Error("surfaceCard.time length must match position length when provided");
}
const n = surfaceCard.position.length;
let dt = null;
if (Array.isArray(surfaceCard.time) && surfaceCard.time.length === n) {
const dts = [];
for (let i = 1; i < n; i += 1) {
dts.push(surfaceCard.time[i] - surfaceCard.time[i - 1]);
}
const spread = Math.max(...dts) - Math.min(...dts);
if (spread > 1e-6) {
return {
ok: false,
issues: [`non-uniform dt (spread=${spread.toExponential(3)})`]
};
}
dt = mean(dts);
}
const pos = surfaceCard.position;
const load = surfaceCard.load;
const cycleClosure = Math.abs(pos[n - 1] - pos[0]);
const issues = [];
if (cycleClosure > (options.cycleTol ?? 0.05)) {
issues.push(`cycle not closed: |pos[last]-pos[0]|=${cycleClosure.toFixed(4)}`);
}
const med = median(load);
const despiked = load.map((v) => {
const dev = Math.abs(v - med);
return dev > (options.spikeSigma ?? 6) * (Math.abs(med) + 1) ? med : v;
});
const spikes = load.reduce((acc, v, i) => acc + (despiked[i] !== v ? 1 : 0), 0);
return {
ok: issues.length === 0,
issues,
samples: n,
uniformDt: dt !== null,
dt,
cycleClosure,
spikesReplaced: spikes,
steadyStateCyclesToDiscard: options.discardCycles ?? 3
};
}
function mean(values) {
return values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1);
}

50
solver-api/src/schema.js Normal file
View File

@@ -0,0 +1,50 @@
export const MVP_FIELDS = [
"WellName",
"Company",
"PumpingSpeed",
"PumpDepth",
"TubingAnchorLocation",
"RodFrictionCoefficient",
"StuffingBoxFriction",
"PumpFriction",
"WaterCut",
"MeasuredDepthArray",
"InclinationFromVerticalArray",
"AzimuthFromNorthArray",
"UnitsSelection",
"UpStrokeDampingFactor",
"DownStrokeDampingFactor",
"NonDimensionalFluidDamping",
"TaperDiameterArray",
"TaperLengthArray",
"TaperModulusArray",
"TaperWeightArray",
"TaperMTSArray",
"RodTypeArray",
"MoldedGuideFrictionRatio",
"WheeledGuideFrictionRatio",
"OtherGuideFrictionRatio",
"PumpDiameter",
"PumpIntakePressure",
"TubingSize",
"TubingGradient",
"PumpFillageOption",
"PercentPumpFillage",
"PercentageUpstrokeTime",
"PercentageDownstrokeTime",
"PumpingUnitID",
"PumpingSpeedOption",
"FluidLevelOilGravity",
"WaterSpecGravity",
"RodGuideTypeArray",
"RodGuideWeightArray",
"SinkerBarDiameter",
"SinkerBarLength"
];
export const REQUIRED_FIELDS = [
"PumpingSpeed",
"PumpDepth",
"MeasuredDepthArray",
"InclinationFromVerticalArray"
];

8
solver-api/src/server.js Normal file
View File

@@ -0,0 +1,8 @@
import { buildApp } from "./app.js";
const app = buildApp();
const port = process.env.PORT || 4400;
app.listen(port, () => {
console.log(`solver-api listening on ${port}`);
});

View File

@@ -0,0 +1,303 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { exec as execCommand } from "node:child_process";
import { promisify } from "node:util";
const SOLVER_STDIO_MAX_BYTES = 10 * 1024 * 1024;
const execAsync = promisify(execCommand);
const STEEL_E = 2.05e11;
const STEEL_RHO = 7850;
const FIBERGLASS_E = 5.5e9; /* order-of-magnitude; may be overridden by modulus array */
const FIBERGLASS_RHO = 1900;
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function mean(values) {
return values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1);
}
function deriveTaperFactor(model) {
const diam = Array.isArray(model.taperDiameterM) ? model.taperDiameterM.filter((v) => Number.isFinite(v) && v > 0) : [];
const lens = Array.isArray(model.taperLengthM) ? model.taperLengthM.filter((v) => Number.isFinite(v) && v > 0) : [];
if (diam.length === 0 || lens.length === 0) return 1.0;
const n = Math.min(diam.length, lens.length);
const weightedDiameter = diam.slice(0, n).reduce((acc, d, i) => acc + d * lens[i], 0);
const totalLength = lens.slice(0, n).reduce((a, b) => a + b, 0);
if (totalLength <= 0) return 1.0;
const dAvg = weightedDiameter / totalLength;
const area = Math.PI * dAvg * dAvg * 0.25;
const refArea = Math.PI * 0.019 * 0.019 * 0.25;
return clamp(area / refArea, 0.65, 1.25);
}
export function deriveTrajectoryFrictionMultiplier(model) {
const md = Array.isArray(model.measuredDepthM) ? model.measuredDepthM : [];
const inc = Array.isArray(model.inclinationRad) ? model.inclinationRad : [];
const azi = Array.isArray(model.azimuthRad) ? model.azimuthRad : [];
if (md.length < 3 || inc.length !== md.length || azi.length !== md.length) return 1.0;
const kappas = [];
for (let i = 1; i < md.length; i += 1) {
const ds = Math.max(md[i] - md[i - 1], 1e-6);
const dInc = inc[i] - inc[i - 1];
const dAzi = azi[i] - azi[i - 1];
const incMid = (inc[i] + inc[i - 1]) * 0.5;
const kappa = Math.sqrt(dInc * dInc + (Math.sin(incMid) * dAzi) ** 2) / ds;
kappas.push(kappa);
}
const kMean = mean(kappas);
return 1 + clamp(kMean * 400, 0, 0.8);
}
function rodTypeToProps(typeCode) {
const t = Math.round(typeCode);
if (t === 2) {
return { E: FIBERGLASS_E, rho: FIBERGLASS_RHO };
}
return { E: STEEL_E, rho: STEEL_RHO };
}
function buildRodNodes(model) {
const nx = 48;
const nodes = nx + 1;
const depthM = model.pumpDepthM ?? model.pumpDepth;
const anchorM = model.tubingAnchorLocationM ?? model.tubingAnchorLocation;
const rodLength = clamp(depthM - anchorM, 250 * 0.3048, 3500 * 0.3048);
const defaultD = 0.019;
const defaultE = STEEL_E;
const defaultRho = STEEL_RHO;
const lens = (model.taperLengthM || []).filter((v) => v > 0);
const dM = (model.taperDiameterM || []).filter((v) => v > 0);
const ePa = model.taperModulusPa || [];
const weightNPerM = model.taperWeightNPerM || [];
const mtsN = model.taperMtsN || [];
const types = model.rodType || [];
const guideW = model.rodGuideWeightNPerM || [];
const areaByNode = new Array(nodes).fill(Math.PI * defaultD * defaultD * 0.25);
const modulusByNode = new Array(nodes).fill(defaultE);
const densityByNode = new Array(nodes).fill(defaultRho);
const weightByNode = new Array(nodes).fill(defaultRho * 9.80665 * (Math.PI * defaultD * defaultD * 0.25));
const mtsByNode = new Array(nodes).fill(8e5);
const guideWeightByNode = new Array(nodes).fill(0);
if (lens.length === 0 || dM.length === 0) {
return {
has_variable_rod: 0,
rod_node_count: nodes,
area_m2: areaByNode,
modulus_pa: modulusByNode,
density_kg_m3: densityByNode,
rod_length_m: rodLength,
nx
};
}
const totalLen = lens.reduce((a, b) => a + b, 0);
const scale = totalLen > 0 ? rodLength / totalLen : 1;
let covered = 0;
let idx = 0;
const segmentLength = rodLength / nx;
for (let i = 0; i < nodes; i += 1) {
const s = i * segmentLength;
while (idx < lens.length - 1 && s > covered + lens[idx] * scale) {
covered += lens[idx] * scale;
idx += 1;
}
const d = dM[Math.min(idx, dM.length - 1)];
const area = Math.PI * clamp(d, 0.008, 0.05) ** 2 * 0.25;
areaByNode[i] = area;
const eFromXml = ePa[Math.min(idx, ePa.length - 1)];
const wFromXml = weightNPerM[Math.min(idx, weightNPerM.length - 1)];
const mtsFromXml = mtsN[Math.min(idx, mtsN.length - 1)];
const gFromXml = guideW[Math.min(idx, guideW.length - 1)];
const typeCode = types[Math.min(idx, types.length - 1)] ?? 3;
const props = rodTypeToProps(typeCode);
modulusByNode[i] = eFromXml && eFromXml > 1e8 ? eFromXml : props.E;
densityByNode[i] = props.rho;
weightByNode[i] = wFromXml && wFromXml > 0 ? wFromXml : props.rho * 9.80665 * area;
mtsByNode[i] = mtsFromXml && mtsFromXml > 0 ? mtsFromXml : 8e5;
guideWeightByNode[i] = gFromXml && gFromXml > 0 ? gFromXml : 0;
}
return {
has_variable_rod: 1,
rod_node_count: nodes,
area_m2: areaByNode,
modulus_pa: modulusByNode,
density_kg_m3: densityByNode,
weight_n_per_m: weightByNode,
mts_n: mtsByNode,
guide_weight_n_per_m: guideWeightByNode,
rod_length_m: rodLength,
nx
};
}
function buildSolverPayload(model, workflow, surfaceCard, options = {}) {
const rod = buildRodNodes(model);
const taperFactor = deriveTaperFactor(model);
const trajMul = deriveTrajectoryFrictionMultiplier(model);
const surveyMd = model.measuredDepthM || [];
const surveyInc = model.inclinationRad || [];
const surveyAzi = model.azimuthRad || [];
const payload = {
schemaVersion: 2,
workflow,
options: {
enableProfiles: Boolean(options.enableProfiles),
enableDiagnosticsDetail: Boolean(options.enableDiagnosticsDetail),
enableFourierBaseline: Boolean(options.enableFourierBaseline),
fourierHarmonics: Number.isFinite(options.fourierHarmonics) ? options.fourierHarmonics : 8
},
model: {
pumping_speed: model.pumpingSpeed,
pump_depth: model.pumpDepthM ?? model.pumpDepth,
tubing_anchor_location: model.tubingAnchorLocationM ?? model.tubingAnchorLocation,
rod_friction_coefficient: model.rodFrictionCoefficient,
stuffing_box_friction: model.stuffingBoxFrictionN ?? model.stuffingBoxFriction,
pump_friction: model.pumpFrictionN ?? model.pumpFriction,
taper_factor: taperFactor,
trajectory_friction_multiplier: trajMul,
fluid_density_kg_m3: model.fluidDensityKgM3 ?? 1000,
gravity: 9.80665,
upstroke_damping: model.upStrokeDamping ?? 0,
downstroke_damping: model.downStrokeDamping ?? 0,
non_dim_damping: model.nonDimensionalFluidDamping ?? 0,
molded_guide_mu_scale: model.moldedGuideFrictionRatio ?? 1,
wheeled_guide_mu_scale: model.wheeledGuideFrictionRatio ?? 1,
other_guide_mu_scale: model.otherGuideFrictionRatio ?? 1,
has_variable_rod: rod.has_variable_rod,
rod_node_count: rod.rod_node_count,
area_m2: rod.area_m2,
modulus_pa: rod.modulus_pa,
density_kg_m3: rod.density_kg_m3,
weight_n_per_m: rod.weight_n_per_m,
mts_n: rod.mts_n,
guide_weight_n_per_m: rod.guide_weight_n_per_m,
survey_md_m: surveyMd,
survey_inc_rad: surveyInc,
survey_azi_rad: surveyAzi,
pump_diameter_m: model.pumpDiameterM ?? (model.pumpDiameter > 2 ? model.pumpDiameter * 0.0254 : model.pumpDiameter),
pump_intake_pressure_pa: model.pumpIntakePressurePa ?? 0,
tubing_id_m: model.tubingInnerDiameterM ?? 0.0762,
percent_upstroke_time: model.percentUpstrokeTime ?? 50,
percent_downstroke_time: model.percentDownstrokeTime ?? 50,
pump_fillage_option: model.pumpFillageOption ?? 0,
percent_pump_fillage: model.percentPumpFillage ?? 0,
sinker_bar_diameter_m: model.sinkerBarDiameterM ?? 0,
sinker_bar_length_m: model.sinkerBarLengthM ?? 0
}
};
if (workflow === "diagnostic" && surfaceCard) {
const pos = surfaceCard.position.map(Number);
const load = surfaceCard.load.map(Number);
const time = Array.isArray(surfaceCard.time) ? surfaceCard.time.map(Number) : null;
payload.surfaceCard = {
position_m: pos,
load_n: load,
time_s: time && time.length === pos.length ? time : undefined
};
}
return JSON.stringify(payload);
}
async function ensureSolverBinary(solverBinaryPath, forceRebuild = false) {
if (!forceRebuild) {
try {
await access(solverBinaryPath);
return;
} catch (_error) {
/* Build on demand when missing. */
}
}
const root = solverBinaryPath.replace(/\/solver-c\/[^/]+$/, "");
const binaryName = solverBinaryPath.split("/").pop();
const mainSource = binaryName === "solver_fea_main" ? "main_fea.c" : "main.c";
const sources = [
`${root}/solver-c/src/solver_common.c`,
`${root}/solver-c/src/json_stdin.c`,
`${root}/solver-c/src/trajectory.c`,
`${root}/solver-c/src/solver_diagnostic.c`,
`${root}/solver-c/src/solver.c`,
`${root}/solver-c/src/solver_fea.c`,
`${root}/solver-c/src/solver_fourier.c`
].join(" ");
const compileCommand = `gcc -std=c99 -I"${root}/solver-c/include" ${sources} "${root}/solver-c/src/${mainSource}" -lm -o "${solverBinaryPath}"`;
await execAsync(compileCommand);
}
function runSolverProcess(solverBinaryPath, jsonPayload) {
return new Promise((resolve, reject) => {
const child = spawn(solverBinaryPath, ["--stdin"], {
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
let outBytes = 0;
let errBytes = 0;
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
outBytes += Buffer.byteLength(chunk, "utf8");
if (outBytes > SOLVER_STDIO_MAX_BYTES) {
child.kill("SIGKILL");
reject(new Error("solver stdout exceeded max buffer"));
return;
}
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
errBytes += Buffer.byteLength(chunk, "utf8");
if (errBytes > SOLVER_STDIO_MAX_BYTES) {
child.kill("SIGKILL");
reject(new Error("solver stderr exceeded max buffer"));
return;
}
stderr += chunk;
});
child.on("error", (error) => reject(error));
child.on("close", (code) => {
if (code !== 0) {
const detail = stderr.trim() || `solver exited with code ${code}`;
reject(new Error(detail));
return;
}
resolve(stdout);
});
child.stdin.write(jsonPayload);
child.stdin.end();
});
}
export async function runSolver(solverBinaryPath, model, workflow = "predictive", surfaceCard = null, options = {}) {
await ensureSolverBinary(solverBinaryPath);
const wf = workflow === "diagnostic" ? "diagnostic" : "predictive";
const jsonPayload = buildSolverPayload(model, wf, surfaceCard, options);
let stdout;
try {
stdout = await runSolverProcess(solverBinaryPath, jsonPayload);
} catch (error) {
if (error.code === "ENOENT") {
await ensureSolverBinary(solverBinaryPath, true);
stdout = await runSolverProcess(solverBinaryPath, jsonPayload);
} else {
throw error;
}
}
return JSON.parse(stdout);
}

186
solver-api/src/xmlParser.js Normal file
View File

@@ -0,0 +1,186 @@
import { parseStringPromise } from "xml2js";
import { MVP_FIELDS, REQUIRED_FIELDS } from "./schema.js";
const FT_TO_M = 0.3048;
const IN_TO_M = 0.0254;
const LBF_TO_N = 4.4482216152605;
const PSI_TO_PA = 6894.757293168;
const MPSI_TO_PA = 6.894757293168e9;
function parseArrayValue(raw) {
return String(raw)
.split(":")
.filter(Boolean)
.map((item) => Number(item));
}
function parseNumeric(raw) {
const value = Number(raw);
if (Number.isNaN(value)) {
throw new Error(`Invalid numeric value: ${raw}`);
}
return value;
}
function normalizeToSi(model, rawFields, warnings) {
const units = Number(rawFields.UnitsSelection ?? 0);
const useImperialOilfield = units === 2 || units === 0; /* 0 treated like legacy imperial in many SROD exports */
if (units === 0) {
warnings.push("UnitsSelection missing; assuming imperial oilfield conversions (heuristic)");
}
if (!useImperialOilfield) {
warnings.push(`UnitsSelection=${units}; treating numeric fields as SI (heuristic)`);
model.pumpDepthM = model.pumpDepth;
model.tubingAnchorLocationM = model.tubingAnchorLocation;
model.measuredDepthM = [...model.measuredDepth];
model.inclinationRad = model.inclination.map((v) => (v * Math.PI) / 180);
model.azimuthRad = model.azimuth.map((v) => (v * Math.PI) / 180);
model.stuffingBoxFrictionN = model.stuffingBoxFriction;
model.pumpFrictionN = model.pumpFriction;
model.taperLengthM = [...(model.taperLength || [])];
model.taperDiameterM = [...(model.taperDiameter || [])];
model.taperModulusPa = [...(model.taperModulus || [])];
model.taperWeightNPerM = [...(model.taperWeight || [])];
model.taperMtsN = [...(model.taperMts || [])];
model.rodGuideWeightNPerM = [...(model.rodGuideWeight || [])];
model.sinkerBarDiameterM = model.sinkerBarDiameter;
model.sinkerBarLengthM = model.sinkerBarLength;
model.pumpIntakePressurePa = model.pumpIntakePressure;
model.tubingGradientPaM = model.tubingGradient;
model.pumpDiameterM = model.pumpDiameter;
model.tubingInnerDiameterM = model.tubingSize > 0 ? model.tubingSize * IN_TO_M : 0.0762;
model.fluidDensityKgM3 = computeFluidDensityKgM3(model);
return;
}
model.pumpDepthM = model.pumpDepth * FT_TO_M;
model.tubingAnchorLocationM = model.tubingAnchorLocation * FT_TO_M;
model.measuredDepthM = model.measuredDepth.map((v) => v * FT_TO_M);
model.inclinationRad = model.inclination.map((v) => (v * Math.PI) / 180);
model.azimuthRad = model.azimuth.map((v) => (v * Math.PI) / 180);
model.stuffingBoxFrictionN = model.stuffingBoxFriction * LBF_TO_N;
model.pumpFrictionN = model.pumpFriction * LBF_TO_N;
model.taperLengthM = (model.taperLength || []).map((v) => v * FT_TO_M);
model.taperDiameterM = (model.taperDiameter || []).map((d) => (d > 2 ? d * IN_TO_M : d));
model.taperModulusPa = (model.taperModulus || []).map((e) => (e > 1e8 ? e : e * MPSI_TO_PA));
model.taperWeightNPerM = (model.taperWeight || []).map((w) => w * LBF_TO_N / FT_TO_M);
model.taperMtsN = (model.taperMts || []).map((v) => v * LBF_TO_N);
model.rodGuideWeightNPerM = (model.rodGuideWeight || []).map((w) => w * LBF_TO_N / FT_TO_M);
model.sinkerBarDiameterM = model.sinkerBarDiameter > 2 ? model.sinkerBarDiameter * IN_TO_M : model.sinkerBarDiameter;
model.sinkerBarLengthM = model.sinkerBarLength * FT_TO_M;
model.pumpIntakePressurePa = model.pumpIntakePressure * PSI_TO_PA;
model.tubingGradientPaM = model.tubingGradient * PSI_TO_PA / FT_TO_M;
model.pumpDiameterM = model.pumpDiameter > 2 ? model.pumpDiameter * IN_TO_M : model.pumpDiameter;
/* TubingSize in base-case is nominal inches code; keep raw for now */
model.tubingInnerDiameterM = model.tubingSize > 0 ? model.tubingSize * IN_TO_M : 0.0762;
model.fluidDensityKgM3 = computeFluidDensityKgM3(model);
}
function computeFluidDensityKgM3(model) {
const wc = Math.max(0, Math.min(100, model.waterCut)) / 100;
const rhoW = 1000 * (model.waterSpecGravity || 1.0);
const api = model.fluidLevelOilGravity || 35;
const rhoOil = 141.5 / (api + 131.5) * 999.012; /* simplified */
const rho = wc * rhoW + (1 - wc) * rhoOil;
if (!Number.isFinite(rho) || rho <= 0) {
return 1000;
}
return rho;
}
export async function parseCaseXml(xmlContent) {
const parsed = await parseStringPromise(xmlContent, {
explicitArray: false,
explicitRoot: true,
trim: true,
mergeAttrs: false
});
const caseNode = parsed?.INPRoot?.Case;
if (!caseNode) {
throw new Error("Missing INPRoot/Case node");
}
const rawFields = {};
for (const [key, value] of Object.entries(caseNode)) {
if (key === "$") continue;
rawFields[key] = value;
}
const warnings = [];
const model = {
wellName: rawFields.WellName || "Unknown",
company: rawFields.Company || "Unknown",
pumpingSpeed: parseNumeric(rawFields.PumpingSpeed ?? 0),
pumpDepth: parseNumeric(rawFields.PumpDepth ?? 0),
tubingAnchorLocation: parseNumeric(rawFields.TubingAnchorLocation ?? 0),
rodFrictionCoefficient: parseNumeric(rawFields.RodFrictionCoefficient ?? 0),
stuffingBoxFriction: parseNumeric(rawFields.StuffingBoxFriction ?? 0),
pumpFriction: parseNumeric(rawFields.PumpFriction ?? 0),
waterCut: parseNumeric(rawFields.WaterCut ?? 0),
waterSpecGravity: parseNumeric(rawFields.WaterSpecGravity ?? 1.0),
fluidLevelOilGravity: parseNumeric(rawFields.FluidLevelOilGravity ?? 0),
measuredDepth: parseArrayValue(rawFields.MeasuredDepthArray ?? ""),
inclination: parseArrayValue(rawFields.InclinationFromVerticalArray ?? ""),
azimuth: parseArrayValue(rawFields.AzimuthFromNorthArray ?? ""),
taperDiameter: parseArrayValue(rawFields.TaperDiameterArray ?? ""),
taperLength: parseArrayValue(rawFields.TaperLengthArray ?? ""),
taperModulus: parseArrayValue(rawFields.TaperModulusArray ?? ""),
taperWeight: parseArrayValue(rawFields.TaperWeightArray ?? ""),
taperMts: parseArrayValue(rawFields.TaperMTSArray ?? ""),
rodType: parseArrayValue(rawFields.RodTypeArray ?? ""),
rodGuideType: String(rawFields.RodGuideTypeArray ?? "")
.split(":")
.filter(Boolean),
rodGuideWeight: parseArrayValue(rawFields.RodGuideWeightArray ?? ""),
tubingSize: parseNumeric(rawFields.TubingSize ?? 0),
unitsSelection: parseNumeric(rawFields.UnitsSelection ?? 0),
upStrokeDamping: parseNumeric(rawFields.UpStrokeDampingFactor ?? 0),
downStrokeDamping: parseNumeric(rawFields.DownStrokeDampingFactor ?? 0),
nonDimensionalFluidDamping: parseNumeric(rawFields.NonDimensionalFluidDamping ?? 0),
moldedGuideFrictionRatio: parseNumeric(rawFields.MoldedGuideFrictionRatio ?? 1.0),
wheeledGuideFrictionRatio: parseNumeric(rawFields.WheeledGuideFrictionRatio ?? 1.0),
otherGuideFrictionRatio: parseNumeric(rawFields.OtherGuideFrictionRatio ?? 1.0),
pumpDiameter: parseNumeric(rawFields.PumpDiameter ?? 0),
pumpIntakePressure: parseNumeric(rawFields.PumpIntakePressure ?? 0),
tubingGradient: parseNumeric(rawFields.TubingGradient ?? 0),
pumpFillageOption: parseNumeric(rawFields.PumpFillageOption ?? 0),
percentPumpFillage: parseNumeric(rawFields.PercentPumpFillage ?? 0),
percentUpstrokeTime: parseNumeric(rawFields.PercentageUpstrokeTime ?? 50),
percentDownstrokeTime: parseNumeric(rawFields.PercentageDownstrokeTime ?? 50),
pumpingUnitId: rawFields.PumpingUnitID || "",
pumpingSpeedOption: parseNumeric(rawFields.PumpingSpeedOption ?? 0),
sinkerBarDiameter: parseNumeric(rawFields.SinkerBarDiameter ?? 0),
sinkerBarLength: parseNumeric(rawFields.SinkerBarLength ?? 0)
};
const missingRequired = REQUIRED_FIELDS.filter((field) => !rawFields[field]);
if (missingRequired.length > 0) {
throw new Error(`Missing required field(s): ${missingRequired.join(", ")}`);
}
if (
model.measuredDepth.length !== model.inclination.length ||
model.measuredDepth.length !== model.azimuth.length
) {
throw new Error("Trajectory arrays must have matching lengths");
}
normalizeToSi(model, rawFields, warnings);
const unsupportedFields = Object.keys(rawFields).filter((field) => !MVP_FIELDS.includes(field));
return {
model,
unsupportedFields,
rawFields,
warnings
};
}

View File

@@ -0,0 +1,137 @@
import fs from "node:fs";
import path from "node:path";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../");
const xml = fs.readFileSync(path.join(ROOT, "data/cases/base-case.xml"), "utf-8");
const defaultGoldenHash = fs
.readFileSync(path.join(ROOT, "data/golden/default.solve.sha256"), "utf-8")
.trim();
describe("solver-api", () => {
it("solves using default base case endpoint", async () => {
const app = buildApp();
const response = await request(app).get("/solve/default");
expect(response.status).toBe(200);
expect(response.body.runMetadata.source).toBe("base-case.xml");
expect(response.body.solver.pointCount).toBe(200);
});
it("returns fea prototype result and comparison payload", async () => {
const app = buildApp();
const response = await request(app).get("/solve/default?solverModel=fea");
expect(response.status).toBe(200);
expect(response.body.runMetadata.solverModel).toBe("fea");
expect(response.body.solver.pointCount).toBe(200);
expect(response.body.solvers.fdm.pointCount).toBe(200);
expect(response.body.solvers.fea.pointCount).toBe(200);
expect(response.body.comparison).toBeTruthy();
expect(response.body.comparison.schemaVersion).toBe(2);
expect(response.body.comparison.peakLoadDeltas).toBeTruthy();
expect(response.body.comparison.residualSummary.points).toBeGreaterThan(0);
expect(Array.isArray(response.body.comparison.pointwiseResiduals.series)).toBe(true);
expect(response.body.comparison.fourier).toBeNull();
});
it("returns both model outputs when requested", async () => {
const app = buildApp();
const response = await request(app).post("/solve").send({ xml, solverModel: "both" });
expect(response.status).toBe(200);
expect(response.body.runMetadata.solverModel).toBe("both");
expect(response.body.solvers.fdm.pointCount).toBe(200);
expect(response.body.solvers.fea.pointCount).toBe(200);
});
it("supports diagnostic workflow from measured polished-rod data", async () => {
const app = buildApp();
const predictive = await request(app).get("/solve/default?solverModel=fdm");
expect(predictive.status).toBe(200);
const card = predictive.body.solver.card.slice(0, 120);
const surfaceCard = {
position: card.map((p) => p.position),
load: card.map((p) => p.polishedLoad)
};
const response = await request(app)
.post("/solve")
.send({ xml, solverModel: "fdm", workflow: "diagnostic", surfaceCard });
expect(response.status).toBe(200);
expect(response.body.runMetadata.workflow).toBe("diagnostic");
expect(response.body.pumpMovement.stroke).toBeTypeOf("number");
expect(response.body.verbose.references.length).toBeGreaterThan(0);
expect(response.body.verbose.rodString.hasTaper).toBe(true);
expect(response.body.verbose.trajectoryCoupling.frictionMultiplier).toBeGreaterThan(1);
});
it("returns parsed case and solver output", async () => {
const app = buildApp();
const response = await request(app).post("/solve").send({ xml });
expect(response.status).toBe(200);
expect(response.body.solver.pointCount).toBe(200);
expect(response.body.parsed.model.wellName).toContain("191/01-27-007-09W2/00");
expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true);
});
it("is deterministic for the same input", async () => {
const app = buildApp();
const a = await request(app).post("/solve").send({ xml });
const b = await request(app).post("/solve").send({ xml });
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(a.body.solver.card).toEqual(b.body.solver.card);
expect(a.body.solver.maxPolishedLoad).toBe(b.body.solver.maxPolishedLoad);
});
it("parses an uploaded XML via POST /case/parse (no solve)", async () => {
const app = buildApp();
const defaultResp = await request(app).get("/case/default");
expect(defaultResp.status).toBe(200);
const response = await request(app).post("/case/parse").send({ xml });
expect(response.status).toBe(200);
expect(response.body.schemaVersion).toBe(2);
expect(response.body.model.wellName).toBe(defaultResp.body.model.wellName);
expect(response.body.model.pumpDepth).toBe(defaultResp.body.model.pumpDepth);
expect(response.body.rawFields.WellName).toBe(defaultResp.body.rawFields.WellName);
expect(response.body.unsupportedFields.sort()).toEqual(
defaultResp.body.unsupportedFields.sort()
);
});
it("rejects empty body on POST /case/parse", async () => {
const app = buildApp();
const response = await request(app).post("/case/parse").send({});
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/xml/i);
expect(response.body.schemaVersion).toBe(2);
});
it("matches golden fingerprint for default solve", async () => {
const app = buildApp();
const response = await request(app).get("/solve/default?solverModel=fdm");
expect(response.status).toBe(200);
expect(response.body.fingerprint).toBe(defaultGoldenHash);
});
it("returns extended physics payload when options are enabled", async () => {
const app = buildApp();
const response = await request(app).post("/solve").send({
xml,
solverModel: "both",
options: {
enableProfiles: true,
enableDiagnosticsDetail: true,
enableFourierBaseline: true,
fourierHarmonics: 10
}
});
expect(response.status).toBe(200);
expect(response.body.solver.profiles.nodeCount).toBeGreaterThan(0);
expect(response.body.solver.diagnostics.valveStates.length).toBe(response.body.solver.pointCount);
expect(response.body.comparison.fourier).toBeTruthy();
expect(response.body.comparison.fourier.harmonics).toBe(10);
});
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
fileParallelism: false,
maxConcurrency: 1,
testTimeout: 120000
}
});

28
solver-c/CMakeLists.txt Normal file
View File

@@ -0,0 +1,28 @@
cmake_minimum_required(VERSION 3.16)
project(rod_solver_c C)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
add_library(rod_solver STATIC
src/solver_common.c
src/json_stdin.c
src/trajectory.c
src/solver_diagnostic.c
src/solver.c
src/solver_fea.c
src/solver_fourier.c
)
target_include_directories(rod_solver PUBLIC include)
add_executable(solver_main src/main.c)
target_link_libraries(solver_main PRIVATE rod_solver m)
add_executable(solver_fea_main src/main_fea.c)
target_link_libraries(solver_fea_main PRIVATE rod_solver m)
add_executable(test_solver tests/test_solver.c)
target_link_libraries(test_solver PRIVATE rod_solver m)
enable_testing()
add_test(NAME solver_c_tests COMMAND test_solver)

131
solver-c/include/solver.h Normal file
View File

@@ -0,0 +1,131 @@
#ifndef SOLVER_H
#define SOLVER_H
#include <stddef.h>
#define SOLVER_MAX_POINTS 512
#define SOLVER_MAX_WARNINGS 16
#define SOLVER_WARNING_LEN 160
#define SOLVER_MAX_NODES 65
#define SOLVER_MAX_SURVEY 512
#define SOLVER_MAX_SURFACE 4096
#define SOLVER_MAX_FOURIER_HARMONICS 32
typedef struct {
int schema_version;
int workflow; /* 0 predictive, 1 diagnostic */
double pumping_speed;
double pump_depth;
double tubing_anchor_location;
double rod_friction_coefficient;
double stuffing_box_friction;
double pump_friction;
double taper_factor;
double trajectory_friction_multiplier;
double fluid_density_kg_m3;
double gravity;
double upstroke_damping;
double downstroke_damping;
double non_dim_damping;
double molded_guide_mu_scale;
double wheeled_guide_mu_scale;
double other_guide_mu_scale;
int has_variable_rod;
int rod_node_count;
double area_m2[SOLVER_MAX_NODES];
double modulus_pa[SOLVER_MAX_NODES];
double density_kg_m3[SOLVER_MAX_NODES];
double weight_n_per_m[SOLVER_MAX_NODES];
double mts_n[SOLVER_MAX_NODES];
double guide_weight_n_per_m[SOLVER_MAX_NODES];
int survey_station_count;
double survey_md_m[SOLVER_MAX_SURVEY];
double survey_inc_rad[SOLVER_MAX_SURVEY];
double survey_azi_rad[SOLVER_MAX_SURVEY];
int geometry_valid;
double node_curvature[SOLVER_MAX_NODES];
double node_inc_rad[SOLVER_MAX_NODES];
double node_azi_rad[SOLVER_MAX_NODES];
double node_side_load_n[SOLVER_MAX_NODES];
int surface_count;
double surface_position_m[SOLVER_MAX_SURFACE];
double surface_load_n[SOLVER_MAX_SURFACE];
int surface_has_time;
double surface_time_s[SOLVER_MAX_SURFACE];
double pump_diameter_m;
double pump_intake_pressure_pa;
double tubing_id_m;
double percent_upstroke_time;
double percent_downstroke_time;
int pump_fillage_option;
double percent_pump_fillage;
double sinker_bar_diameter_m;
double sinker_bar_length_m;
int enable_profiles;
int enable_diagnostics_detail;
int enable_fourier_baseline;
int fourier_harmonics;
} SolverInputs;
typedef struct {
int point_count;
double position[SOLVER_MAX_POINTS];
double polished_load[SOLVER_MAX_POINTS];
double downhole_load[SOLVER_MAX_POINTS];
double pump_position_m[SOLVER_MAX_POINTS];
double pump_velocity_m_s[SOLVER_MAX_POINTS];
double polished_stress_pa[SOLVER_MAX_POINTS];
double side_load_profile_n[SOLVER_MAX_POINTS];
int profile_node_count;
double profile_md_m[SOLVER_MAX_NODES];
double profile_curvature_1pm[SOLVER_MAX_NODES];
double profile_inclination_rad[SOLVER_MAX_NODES];
double profile_azimuth_rad[SOLVER_MAX_NODES];
double profile_side_load_n[SOLVER_MAX_NODES];
double profile_friction_n[SOLVER_MAX_NODES];
int valve_traveling_open[SOLVER_MAX_POINTS];
int valve_standing_open[SOLVER_MAX_POINTS];
double chamber_pressure_pa[SOLVER_MAX_POINTS];
double gas_fraction[SOLVER_MAX_POINTS];
int fourier_harmonics_used;
double fourier_polished_load[SOLVER_MAX_POINTS];
double fourier_downhole_load[SOLVER_MAX_POINTS];
double fourier_residual_rms_polished;
double fourier_residual_rms_downhole;
double max_polished_load;
double min_polished_load;
double max_downhole_load;
double min_downhole_load;
int gas_interference;
double max_cfl;
double wave_speed_ref_m_s;
char warnings[SOLVER_MAX_WARNINGS][SOLVER_WARNING_LEN];
int warning_count;
} SolverOutputs;
/* json_stdin.c */
int solver_read_json_stdin(char **buffer_out, size_t *length_out);
int solver_parse_json_inputs(const char *buffer, SolverInputs *inputs);
/* trajectory.c */
void solver_trajectory_preprocess(SolverInputs *inputs, int nx, double rod_length_m);
/* solver_diagnostic.c */
int solver_run_diagnostic_fdm(const SolverInputs *inputs, SolverOutputs *outputs);
int solver_run_fdm(const SolverInputs *inputs, SolverOutputs *outputs);
int solver_run_fea(const SolverInputs *inputs, SolverOutputs *outputs);
int solver_run(const SolverInputs *inputs, SolverOutputs *outputs);
int solver_compute_fourier_baseline(const SolverInputs *inputs, SolverOutputs *outputs);
#endif

View File

@@ -0,0 +1,19 @@
#ifndef SOLVER_INTERNAL_H
#define SOLVER_INTERNAL_H
#include "solver.h"
double solver_clamp(double v, double lo, double hi);
double solver_signum(double v);
void solver_add_warning(SolverOutputs *outputs, const char *msg);
void solver_init_output_ranges(SolverOutputs *outputs);
void solver_update_output_ranges(SolverOutputs *outputs, double polished, double downhole);
double solver_input_or_default(double value, double fallback);
double solver_compute_side_load_node(const SolverInputs *inputs, double tension_n, int node_idx, double ds);
double solver_compute_friction_node(const SolverInputs *inputs, double side_load_n, double velocity_m_s, int node_idx);
void solver_fill_profiles(const SolverInputs *inputs, SolverOutputs *outputs, int node_count, double rod_length_m,
const double *side_load_nodes, const double *friction_nodes);
void solver_valve_state_step(const SolverInputs *inputs, SolverOutputs *outputs, int step_idx, double pump_position_m,
double pump_velocity_m_s, double downhole_load_n);
#endif

360
solver-c/src/json_stdin.c Normal file
View File

@@ -0,0 +1,360 @@
#include "solver.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define JSON_READ_CHUNK 65536
#define JSON_MAX_SIZE (4 * 1024 * 1024)
int solver_read_json_stdin(char **buffer_out, size_t *length_out) {
size_t cap = JSON_READ_CHUNK;
size_t len = 0;
char *buf = (char *)malloc(cap);
if (!buf) {
return -1;
}
for (;;) {
size_t n = fread(buf + len, 1, JSON_READ_CHUNK, stdin);
len += n;
if (n < JSON_READ_CHUNK) {
break;
}
if (len + JSON_READ_CHUNK > JSON_MAX_SIZE) {
free(buf);
return -2;
}
if (len + JSON_READ_CHUNK > cap) {
cap *= 2;
char *nb = (char *)realloc(buf, cap);
if (!nb) {
free(buf);
return -3;
}
buf = nb;
}
}
buf[len] = '\0';
*buffer_out = buf;
*length_out = len;
return 0;
}
static void skip_ws(const char **p) {
while (**p && isspace((unsigned char)**p)) {
(*p)++;
}
}
static const char *find_matching_brace(const char *p) {
if (*p != '{') {
return NULL;
}
int depth = 0;
const char *s = p;
for (; *s; s++) {
if (*s == '{') depth++;
else if (*s == '}') {
depth--;
if (depth == 0) {
return s;
}
}
}
return NULL;
}
static int parse_string_literal(const char **p, char *out, size_t out_cap) {
skip_ws(p);
if (**p != '"') {
return -1;
}
(*p)++;
size_t i = 0;
while (**p && **p != '"' && i + 1 < out_cap) {
out[i++] = *(*p)++;
}
out[i] = '\0';
if (**p == '"') {
(*p)++;
}
return 0;
}
static int parse_number(const char **p, double *out) {
skip_ws(p);
char *end = NULL;
double v = strtod(*p, &end);
if (end == *p) {
return -1;
}
*p = end;
*out = v;
return 0;
}
static int parse_bool(const char **p, int *out) {
skip_ws(p);
if (strncmp(*p, "true", 4) == 0) {
*p += 4;
*out = 1;
return 0;
}
if (strncmp(*p, "false", 5) == 0) {
*p += 5;
*out = 0;
return 0;
}
return -1;
}
static int parse_double_array(const char **p, double *out, int max_n, int *count_out) {
skip_ws(p);
if (**p != '[') {
return -1;
}
(*p)++;
int n = 0;
for (;;) {
skip_ws(p);
if (**p == ']') {
(*p)++;
break;
}
if (n >= max_n) {
return -2;
}
if (parse_number(p, &out[n]) != 0) {
return -3;
}
n++;
skip_ws(p);
if (**p == ',') {
(*p)++;
continue;
}
if (**p == ']') {
(*p)++;
break;
}
return -4;
}
*count_out = n;
return 0;
}
static const char *object_find(const char *obj_start, const char *obj_end, const char *key) {
char pattern[96];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *hit = obj_start;
while (hit < obj_end) {
hit = strstr(hit, pattern);
if (!hit || hit >= obj_end) {
return NULL;
}
if (hit > obj_start && (isalnum((unsigned char)hit[-1]) || hit[-1] == '_')) {
hit += 1;
continue;
}
const char *colon = strchr(hit + strlen(pattern), ':');
if (!colon || colon >= obj_end) {
return NULL;
}
colon++;
while (colon < obj_end && isspace((unsigned char)*colon)) {
colon++;
}
return colon;
}
return NULL;
}
static int parse_object_number(const char *obj_start, const char *obj_end, const char *key, double *out) {
const char *p = object_find(obj_start, obj_end, key);
if (!p) {
return -1;
}
const char *q = p;
if (parse_number(&q, out) != 0) {
return -2;
}
return 0;
}
static int parse_object_int(const char *obj_start, const char *obj_end, const char *key, int *out) {
double v = 0.0;
if (parse_object_number(obj_start, obj_end, key, &v) != 0) {
return -1;
}
*out = (int)v;
return 0;
}
static int parse_object_bool(const char *obj_start, const char *obj_end, const char *key, int *out) {
const char *p = object_find(obj_start, obj_end, key);
if (!p) {
return -1;
}
const char *q = p;
return parse_bool(&q, out);
}
static int parse_object_array(const char *obj_start, const char *obj_end, const char *key, double *arr, int max_n, int *count_out) {
const char *p = object_find(obj_start, obj_end, key);
if (!p) {
return -1;
}
const char *q = p;
return parse_double_array(&q, arr, max_n, count_out);
}
int solver_parse_json_inputs(const char *buffer, SolverInputs *inputs) {
if (!buffer || !inputs) {
return -1;
}
memset(inputs, 0, sizeof(SolverInputs));
inputs->schema_version = 2;
inputs->workflow = 0;
inputs->gravity = 9.80665;
inputs->fluid_density_kg_m3 = 1000.0;
inputs->taper_factor = 1.0;
inputs->trajectory_friction_multiplier = 1.0;
inputs->molded_guide_mu_scale = 1.0;
inputs->wheeled_guide_mu_scale = 1.0;
inputs->other_guide_mu_scale = 1.0;
inputs->percent_upstroke_time = 50.0;
inputs->percent_downstroke_time = 50.0;
inputs->enable_profiles = 0;
inputs->enable_diagnostics_detail = 0;
inputs->enable_fourier_baseline = 0;
inputs->fourier_harmonics = 8;
const char *wf = strstr(buffer, "\"workflow\"");
if (wf) {
const char *colon = strchr(wf, ':');
if (colon) {
colon++;
while (*colon && isspace((unsigned char)*colon)) colon++;
char tmp[32];
const char *q = colon;
if (parse_string_literal(&q, tmp, sizeof(tmp)) == 0) {
if (strcmp(tmp, "diagnostic") == 0) {
inputs->workflow = 1;
}
}
}
}
const char *model_key = strstr(buffer, "\"model\"");
if (!model_key) {
return -2;
}
const char *brace = strchr(model_key, '{');
if (!brace) {
return -3;
}
const char *model_end = find_matching_brace(brace);
if (!model_end) {
return -4;
}
/* required scalars with defaults if missing */
(void)parse_object_number(brace, model_end, "pumping_speed", &inputs->pumping_speed);
(void)parse_object_number(brace, model_end, "pump_depth", &inputs->pump_depth);
(void)parse_object_number(brace, model_end, "tubing_anchor_location", &inputs->tubing_anchor_location);
(void)parse_object_number(brace, model_end, "rod_friction_coefficient", &inputs->rod_friction_coefficient);
(void)parse_object_number(brace, model_end, "stuffing_box_friction", &inputs->stuffing_box_friction);
(void)parse_object_number(brace, model_end, "pump_friction", &inputs->pump_friction);
(void)parse_object_number(brace, model_end, "taper_factor", &inputs->taper_factor);
(void)parse_object_number(brace, model_end, "trajectory_friction_multiplier", &inputs->trajectory_friction_multiplier);
(void)parse_object_number(brace, model_end, "fluid_density_kg_m3", &inputs->fluid_density_kg_m3);
(void)parse_object_number(brace, model_end, "gravity", &inputs->gravity);
(void)parse_object_number(brace, model_end, "upstroke_damping", &inputs->upstroke_damping);
(void)parse_object_number(brace, model_end, "downstroke_damping", &inputs->downstroke_damping);
(void)parse_object_number(brace, model_end, "non_dim_damping", &inputs->non_dim_damping);
(void)parse_object_number(brace, model_end, "molded_guide_mu_scale", &inputs->molded_guide_mu_scale);
(void)parse_object_number(brace, model_end, "wheeled_guide_mu_scale", &inputs->wheeled_guide_mu_scale);
(void)parse_object_number(brace, model_end, "other_guide_mu_scale", &inputs->other_guide_mu_scale);
(void)parse_object_number(brace, model_end, "pump_diameter_m", &inputs->pump_diameter_m);
(void)parse_object_number(brace, model_end, "pump_intake_pressure_pa", &inputs->pump_intake_pressure_pa);
(void)parse_object_number(brace, model_end, "tubing_id_m", &inputs->tubing_id_m);
(void)parse_object_number(brace, model_end, "percent_upstroke_time", &inputs->percent_upstroke_time);
(void)parse_object_number(brace, model_end, "percent_downstroke_time", &inputs->percent_downstroke_time);
(void)parse_object_int(brace, model_end, "pump_fillage_option", &inputs->pump_fillage_option);
(void)parse_object_number(brace, model_end, "percent_pump_fillage", &inputs->percent_pump_fillage);
(void)parse_object_number(brace, model_end, "sinker_bar_diameter_m", &inputs->sinker_bar_diameter_m);
(void)parse_object_number(brace, model_end, "sinker_bar_length_m", &inputs->sinker_bar_length_m);
int hr = 0;
if (parse_object_bool(brace, model_end, "has_variable_rod", &hr) == 0) {
inputs->has_variable_rod = hr;
}
(void)parse_object_int(brace, model_end, "rod_node_count", &inputs->rod_node_count);
int na = 0, ne = 0, nd = 0;
if (parse_object_array(brace, model_end, "area_m2", inputs->area_m2, SOLVER_MAX_NODES, &na) == 0) {
(void)na;
}
if (parse_object_array(brace, model_end, "modulus_pa", inputs->modulus_pa, SOLVER_MAX_NODES, &ne) == 0) {
(void)ne;
}
if (parse_object_array(brace, model_end, "density_kg_m3", inputs->density_kg_m3, SOLVER_MAX_NODES, &nd) == 0) {
(void)nd;
}
int nw = 0, nm = 0, ng = 0;
(void)parse_object_array(brace, model_end, "weight_n_per_m", inputs->weight_n_per_m, SOLVER_MAX_NODES, &nw);
(void)parse_object_array(brace, model_end, "mts_n", inputs->mts_n, SOLVER_MAX_NODES, &nm);
(void)parse_object_array(brace, model_end, "guide_weight_n_per_m", inputs->guide_weight_n_per_m, SOLVER_MAX_NODES, &ng);
if (na > 0 && ne > 0 && nd > 0 && na == ne && na == nd) {
inputs->has_variable_rod = 1;
inputs->rod_node_count = na;
}
int ns = 0;
if (parse_object_array(brace, model_end, "survey_md_m", inputs->survey_md_m, SOLVER_MAX_SURVEY, &ns) == 0 && ns > 0) {
int ni = 0, nz = 0;
if (parse_object_array(brace, model_end, "survey_inc_rad", inputs->survey_inc_rad, SOLVER_MAX_SURVEY, &ni) == 0 &&
parse_object_array(brace, model_end, "survey_azi_rad", inputs->survey_azi_rad, SOLVER_MAX_SURVEY, &nz) == 0 &&
ni == ns && nz == ns) {
inputs->survey_station_count = ns;
}
}
const char *sc = strstr(buffer, "\"surfaceCard\"");
if (sc && inputs->workflow == 1) {
const char *b = strchr(sc, '{');
if (b) {
const char *end = find_matching_brace(b);
if (end) {
int np = 0, nl = 0;
if (parse_object_array(b, end, "position_m", inputs->surface_position_m, SOLVER_MAX_SURFACE, &np) == 0 &&
parse_object_array(b, end, "load_n", inputs->surface_load_n, SOLVER_MAX_SURFACE, &nl) == 0 && np == nl && np > 0) {
inputs->surface_count = np;
int nt = 0;
if (parse_object_array(b, end, "time_s", inputs->surface_time_s, SOLVER_MAX_SURFACE, &nt) == 0 && nt == np) {
inputs->surface_has_time = 1;
}
}
}
}
}
const char *opt = strstr(buffer, "\"options\"");
if (opt) {
const char *b = strchr(opt, '{');
if (b) {
const char *end = find_matching_brace(b);
if (end) {
(void)parse_object_bool(b, end, "enableProfiles", &inputs->enable_profiles);
(void)parse_object_bool(b, end, "enableDiagnosticsDetail", &inputs->enable_diagnostics_detail);
(void)parse_object_bool(b, end, "enableFourierBaseline", &inputs->enable_fourier_baseline);
(void)parse_object_int(b, end, "fourierHarmonics", &inputs->fourier_harmonics);
}
}
}
if (inputs->fourier_harmonics < 1) inputs->fourier_harmonics = 1;
if (inputs->fourier_harmonics > SOLVER_MAX_FOURIER_HARMONICS) inputs->fourier_harmonics = SOLVER_MAX_FOURIER_HARMONICS;
return 0;
}

177
solver-c/src/main.c Normal file
View File

@@ -0,0 +1,177 @@
#include "solver.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void print_json_output(const SolverOutputs *outputs) {
printf("{\n");
printf(" \"pointCount\": %d,\n", outputs->point_count);
printf(" \"maxPolishedLoad\": %.6f,\n", outputs->max_polished_load);
printf(" \"minPolishedLoad\": %.6f,\n", outputs->min_polished_load);
printf(" \"maxDownholeLoad\": %.6f,\n", outputs->max_downhole_load);
printf(" \"minDownholeLoad\": %.6f,\n", outputs->min_downhole_load);
printf(" \"gasInterference\": %s,\n", outputs->gas_interference ? "true" : "false");
printf(" \"maxCfl\": %.6f,\n", outputs->max_cfl);
printf(" \"waveSpeedRefMPerS\": %.6f,\n", outputs->wave_speed_ref_m_s);
printf(" \"warnings\": [");
for (int i = 0; i < outputs->warning_count; i++) {
if (i > 0) printf(", ");
printf("\"%s\"", outputs->warnings[i]);
}
printf("],\n");
printf(" \"card\": [\n");
for (int i = 0; i < outputs->point_count; i++) {
printf(" {\"position\": %.6f, \"polishedLoad\": %.6f, \"downholeLoad\": %.6f, \"polishedStressPa\": %.6f, \"sideLoadN\": %.6f}%s\n",
outputs->position[i],
outputs->polished_load[i],
outputs->downhole_load[i],
outputs->polished_stress_pa[i],
outputs->side_load_profile_n[i],
(i == outputs->point_count - 1) ? "" : ",");
}
printf(" ],\n");
double pmin = outputs->pump_position_m[0];
double pmax = outputs->pump_position_m[0];
for (int i = 1; i < outputs->point_count; i++) {
if (outputs->pump_position_m[i] < pmin) pmin = outputs->pump_position_m[i];
if (outputs->pump_position_m[i] > pmax) pmax = outputs->pump_position_m[i];
}
printf(" \"pumpMovement\": {\n");
printf(" \"stroke\": %.6f,\n", pmax - pmin);
printf(" \"position\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->pump_position_m[i]);
}
printf("],\n");
printf(" \"velocity\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->pump_velocity_m_s[i]);
}
printf("]\n");
printf(" },\n");
printf(" \"profiles\": {\n");
printf(" \"nodeCount\": %d,\n", outputs->profile_node_count);
printf(" \"trajectory3D\": [");
for (int i = 0; i < outputs->profile_node_count; i++) {
if (i > 0) printf(", ");
printf("{\"md\": %.6f, \"curvature\": %.9f, \"inclination\": %.9f, \"azimuth\": %.9f}",
outputs->profile_md_m[i],
outputs->profile_curvature_1pm[i],
outputs->profile_inclination_rad[i],
outputs->profile_azimuth_rad[i]);
}
printf("],\n");
printf(" \"sideLoadProfile\": [");
for (int i = 0; i < outputs->profile_node_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->profile_side_load_n[i]);
}
printf("],\n");
printf(" \"frictionProfile\": [");
for (int i = 0; i < outputs->profile_node_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->profile_friction_n[i]);
}
printf("]\n");
printf(" },\n");
printf(" \"diagnostics\": {\n");
printf(" \"valveStates\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("{\"travelingOpen\": %s, \"standingOpen\": %s}",
outputs->valve_traveling_open[i] ? "true" : "false",
outputs->valve_standing_open[i] ? "true" : "false");
}
printf("],\n");
printf(" \"chamberPressurePa\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->chamber_pressure_pa[i]);
}
printf("],\n");
printf(" \"gasFraction\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->gas_fraction[i]);
}
printf("]\n");
printf(" },\n");
printf(" \"fourierBaseline\": ");
if (outputs->fourier_harmonics_used > 0) {
printf("{\"harmonics\": %d, \"residualRmsPolished\": %.6f, \"residualRmsDownhole\": %.6f, \"card\": [",
outputs->fourier_harmonics_used,
outputs->fourier_residual_rms_polished,
outputs->fourier_residual_rms_downhole);
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("{\"position\": %.6f, \"polishedLoad\": %.6f, \"downholeLoad\": %.6f}",
outputs->position[i],
outputs->fourier_polished_load[i],
outputs->fourier_downhole_load[i]);
}
printf("]}");
} else {
printf("null");
}
printf("\n");
printf("}\n");
}
int main(int argc, char **argv) {
char *buf = NULL;
size_t len = 0;
if (argc > 1 && strcmp(argv[1], "--stdin") == 0) {
if (solver_read_json_stdin(&buf, &len) != 0) {
fprintf(stderr, "failed to read stdin json\n");
return 1;
}
} else if (argc == 2) {
/* allow file path for tests */
FILE *fp = fopen(argv[1], "rb");
if (!fp) {
fprintf(stderr, "usage: solver_main --stdin OR solver_main <path.json>\n");
return 1;
}
fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
fseek(fp, 0, SEEK_SET);
buf = (char *)malloc((size_t)sz + 1);
fread(buf, 1, (size_t)sz, fp);
fclose(fp);
buf[sz] = '\0';
} else {
fprintf(stderr, "usage: solver_main --stdin OR solver_main <path.json>\n");
return 1;
}
SolverInputs inputs;
if (solver_parse_json_inputs(buf, &inputs) != 0) {
fprintf(stderr, "failed to parse json inputs\n");
free(buf);
return 2;
}
free(buf);
SolverOutputs outputs;
int rc = 0;
if (inputs.workflow == 1) {
rc = solver_run_diagnostic_fdm(&inputs, &outputs);
} else {
rc = solver_run_fdm(&inputs, &outputs);
}
if (rc != 0) {
fprintf(stderr, "solver failed: %d\n", rc);
return 3;
}
print_json_output(&outputs);
return 0;
}

170
solver-c/src/main_fea.c Normal file
View File

@@ -0,0 +1,170 @@
#include "solver.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void print_json_output(const SolverOutputs *outputs) {
printf("{\n");
printf(" \"pointCount\": %d,\n", outputs->point_count);
printf(" \"maxPolishedLoad\": %.6f,\n", outputs->max_polished_load);
printf(" \"minPolishedLoad\": %.6f,\n", outputs->min_polished_load);
printf(" \"maxDownholeLoad\": %.6f,\n", outputs->max_downhole_load);
printf(" \"minDownholeLoad\": %.6f,\n", outputs->min_downhole_load);
printf(" \"gasInterference\": %s,\n", outputs->gas_interference ? "true" : "false");
printf(" \"maxCfl\": %.6f,\n", outputs->max_cfl);
printf(" \"waveSpeedRefMPerS\": %.6f,\n", outputs->wave_speed_ref_m_s);
printf(" \"warnings\": [");
for (int i = 0; i < outputs->warning_count; i++) {
if (i > 0) printf(", ");
printf("\"%s\"", outputs->warnings[i]);
}
printf("],\n");
printf(" \"card\": [\n");
for (int i = 0; i < outputs->point_count; i++) {
printf(" {\"position\": %.6f, \"polishedLoad\": %.6f, \"downholeLoad\": %.6f, \"polishedStressPa\": %.6f, \"sideLoadN\": %.6f}%s\n",
outputs->position[i],
outputs->polished_load[i],
outputs->downhole_load[i],
outputs->polished_stress_pa[i],
outputs->side_load_profile_n[i],
(i == outputs->point_count - 1) ? "" : ",");
}
printf(" ],\n");
double pmin = outputs->pump_position_m[0];
double pmax = outputs->pump_position_m[0];
for (int i = 1; i < outputs->point_count; i++) {
if (outputs->pump_position_m[i] < pmin) pmin = outputs->pump_position_m[i];
if (outputs->pump_position_m[i] > pmax) pmax = outputs->pump_position_m[i];
}
printf(" \"pumpMovement\": {\n");
printf(" \"stroke\": %.6f,\n", pmax - pmin);
printf(" \"position\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->pump_position_m[i]);
}
printf("],\n");
printf(" \"velocity\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->pump_velocity_m_s[i]);
}
printf("]\n");
printf(" },\n");
printf(" \"profiles\": {\n");
printf(" \"nodeCount\": %d,\n", outputs->profile_node_count);
printf(" \"trajectory3D\": [");
for (int i = 0; i < outputs->profile_node_count; i++) {
if (i > 0) printf(", ");
printf("{\"md\": %.6f, \"curvature\": %.9f, \"inclination\": %.9f, \"azimuth\": %.9f}",
outputs->profile_md_m[i],
outputs->profile_curvature_1pm[i],
outputs->profile_inclination_rad[i],
outputs->profile_azimuth_rad[i]);
}
printf("],\n");
printf(" \"sideLoadProfile\": [");
for (int i = 0; i < outputs->profile_node_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->profile_side_load_n[i]);
}
printf("],\n");
printf(" \"frictionProfile\": [");
for (int i = 0; i < outputs->profile_node_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->profile_friction_n[i]);
}
printf("]\n");
printf(" },\n");
printf(" \"diagnostics\": {\n");
printf(" \"valveStates\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("{\"travelingOpen\": %s, \"standingOpen\": %s}",
outputs->valve_traveling_open[i] ? "true" : "false",
outputs->valve_standing_open[i] ? "true" : "false");
}
printf("],\n");
printf(" \"chamberPressurePa\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->chamber_pressure_pa[i]);
}
printf("],\n");
printf(" \"gasFraction\": [");
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("%.6f", outputs->gas_fraction[i]);
}
printf("]\n");
printf(" },\n");
printf(" \"fourierBaseline\": ");
if (outputs->fourier_harmonics_used > 0) {
printf("{\"harmonics\": %d, \"residualRmsPolished\": %.6f, \"residualRmsDownhole\": %.6f, \"card\": [",
outputs->fourier_harmonics_used,
outputs->fourier_residual_rms_polished,
outputs->fourier_residual_rms_downhole);
for (int i = 0; i < outputs->point_count; i++) {
if (i > 0) printf(", ");
printf("{\"position\": %.6f, \"polishedLoad\": %.6f, \"downholeLoad\": %.6f}",
outputs->position[i],
outputs->fourier_polished_load[i],
outputs->fourier_downhole_load[i]);
}
printf("]}");
} else {
printf("null");
}
printf("\n");
printf("}\n");
}
int main(int argc, char **argv) {
char *buf = NULL;
if (argc > 1 && strcmp(argv[1], "--stdin") == 0) {
size_t len = 0;
if (solver_read_json_stdin(&buf, &len) != 0) {
fprintf(stderr, "failed to read stdin json\n");
return 1;
}
} else if (argc == 2) {
FILE *fp = fopen(argv[1], "rb");
if (!fp) {
fprintf(stderr, "usage: solver_fea_main --stdin OR solver_fea_main <path.json>\n");
return 1;
}
fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
fseek(fp, 0, SEEK_SET);
buf = (char *)malloc((size_t)sz + 1);
fread(buf, 1, (size_t)sz, fp);
fclose(fp);
buf[sz] = '\0';
} else {
fprintf(stderr, "usage: solver_fea_main --stdin OR solver_fea_main <path.json>\n");
return 1;
}
SolverInputs inputs;
if (solver_parse_json_inputs(buf, &inputs) != 0) {
fprintf(stderr, "failed to parse json inputs\n");
free(buf);
return 2;
}
free(buf);
SolverOutputs outputs;
if (solver_run_fea(&inputs, &outputs) != 0) {
fprintf(stderr, "solver failed\n");
return 3;
}
print_json_output(&outputs);
return 0;
}

207
solver-c/src/solver.c Normal file
View File

@@ -0,0 +1,207 @@
#include "solver.h"
#include "solver_internal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define FDM_NX 48
static void build_default_rod_nodes(SolverInputs *inputs, int nx, double rod_length_m) {
const int nodes = nx + 1;
const double E = 2.05e11;
const double rho = 7850.0;
const double taper = solver_input_or_default(inputs->taper_factor, 1.0);
const double area = solver_clamp((0.00042 + inputs->rod_friction_coefficient * 0.00008) * taper, 0.00025, 0.0009);
inputs->has_variable_rod = 1;
inputs->rod_node_count = nodes;
for (int i = 0; i < nodes; i++) {
inputs->area_m2[i] = area;
inputs->modulus_pa[i] = E;
inputs->density_kg_m3[i] = rho;
}
(void)rod_length_m;
}
static double segment_mean(const double *a, int i, int max_i) {
const int j = i + 1 > max_i ? max_i : i + 1;
return 0.5 * (a[i] + a[j]);
}
/*
* Explicit FDM core matches legacy stable implementation (pre-JSON refactor).
* Variable rod arrays are populated for API/verbose; dynamics use scalar EA.
*/
int solver_run_fdm(const SolverInputs *inputs_in, SolverOutputs *outputs) {
if (inputs_in == NULL || outputs == NULL) {
return -1;
}
SolverInputs inputs_local = *inputs_in;
SolverInputs *inputs = &inputs_local;
memset(outputs, 0, sizeof(SolverOutputs));
outputs->point_count = 200;
solver_add_warning(outputs, "FDM model: damped 1D wave equation solved in time-space grid");
if (inputs->pumping_speed <= 0.0) {
solver_add_warning(outputs, "Non-positive pumping speed, using fallback 1.0 SPM");
}
if (inputs->pump_depth <= 0.0) {
solver_add_warning(outputs, "Non-positive pump depth, using fallback depth");
}
const double spm = solver_input_or_default(inputs->pumping_speed, 1.0);
const double depth = solver_input_or_default(inputs->pump_depth, 1000.0);
const double anchor = solver_input_or_default(inputs->tubing_anchor_location, depth * 0.8);
const double rod_length = solver_clamp(depth - anchor, 250.0, 3500.0);
const double period = 60.0 / spm;
const double dt = period / (double)(outputs->point_count - 1);
const double dx = rod_length / (double)FDM_NX;
const int nx = FDM_NX;
if (!inputs->has_variable_rod || inputs->rod_node_count != nx + 1) {
build_default_rod_nodes(inputs, nx, rod_length);
}
solver_trajectory_preprocess(inputs, nx, rod_length);
const double E = 2.05e11;
const double rho = 7850.0;
const double taper_factor = solver_clamp(solver_input_or_default(inputs->taper_factor, 1.0), 0.65, 1.25);
const double traj_fric_mul = solver_clamp(solver_input_or_default(inputs->trajectory_friction_multiplier, 1.0), 1.0, 1.8);
const double area = solver_clamp((0.00042 + inputs->rod_friction_coefficient * 0.00008) * taper_factor, 0.00025, 0.0009);
const double a = sqrt(E / rho);
outputs->wave_speed_ref_m_s = a;
const double cfl = a * dt / dx;
/* Tighter explicit stability margin than legacy 0.95 for stiff pump BC + coarse grid */
const double cfl_eff = (cfl > 0.95) ? 0.95 : cfl;
outputs->max_cfl = cfl;
const double r = cfl_eff * cfl_eff;
const double gamma = solver_clamp(0.08 + inputs->rod_friction_coefficient * 0.25, 0.05, 0.3);
const double damping = gamma * dt;
const double surf_amp = solver_clamp(depth * 0.00045, 0.4, 2.6);
const double baseline = depth * 2.05 + 3900.0;
const double k_pump = solver_clamp(1.8e5 + depth * 180.0, 1.2e5, 9.0e5);
const double c_pump = solver_clamp(220.0 + inputs->rod_friction_coefficient * 800.0, 120.0, 1200.0);
const double pump_friction = solver_clamp(inputs->pump_friction * traj_fric_mul, 0.0, 7000.0);
const double stuffing_friction = solver_clamp(inputs->stuffing_box_friction * traj_fric_mul, 0.0, 3500.0);
double side_nodes[SOLVER_MAX_NODES];
double fric_nodes[SOLVER_MAX_NODES];
memset(side_nodes, 0, sizeof(side_nodes));
memset(fric_nodes, 0, sizeof(fric_nodes));
if (spm > 20.0 || cfl > 1.0) {
solver_add_warning(outputs, "FDM settings near CFL limit; solution uses stabilized time step");
}
if (fabs(taper_factor - 1.0) > 1e-6 || fabs(traj_fric_mul - 1.0) > 1e-6) {
solver_add_warning(outputs, "FDM using taper and trajectory-coupled coefficients");
}
solver_init_output_ranges(outputs);
double u_prev[FDM_NX + 1];
double u_curr[FDM_NX + 1];
double u_next[FDM_NX + 1];
memset(u_prev, 0, sizeof(u_prev));
memset(u_curr, 0, sizeof(u_curr));
memset(u_next, 0, sizeof(u_next));
const double omega = 2.0 * M_PI / period;
for (int n = 0; n < outputs->point_count; n++) {
const double t = n * dt;
const double u0 = surf_amp * sin(omega * t);
const double v0 = surf_amp * omega * cos(omega * t);
u_curr[0] = u0;
if (n == 0) {
for (int i = 1; i <= nx; i++) {
u_curr[i] = u0 * (1.0 - (double)i / (double)nx);
u_prev[i] = u_curr[i];
}
}
for (int i = 1; i < nx; i++) {
const double lap = u_curr[i + 1] - 2.0 * u_curr[i] + u_curr[i - 1];
const double v_i = (u_curr[i] - u_prev[i]) / dt;
const double t_i = E * area * (u_curr[i] - u_curr[i - 1]) / dx;
const double side_i = solver_compute_side_load_node(inputs, t_i, i, dx);
const double fric_i = solver_compute_friction_node(inputs, side_i, v_i, i);
side_nodes[i] = side_i;
fric_nodes[i] = fric_i;
const double body = -(side_i + fric_i) * dx / fmax(E * area, 1e-6);
u_next[i] = (2.0 - damping) * u_curr[i] - (1.0 - damping) * u_prev[i] + r * lap + body;
}
const double uN = u_curr[nx];
const double vN = (u_curr[nx] - u_prev[nx]) / dt;
const double tension_end = E * area * (u_curr[nx] - u_curr[nx - 1]) / dx;
const double side_mid = solver_compute_side_load_node(inputs, tension_end, nx, dx);
const double friction_mid = solver_compute_friction_node(inputs, side_mid, vN, nx);
side_nodes[nx] = side_mid;
fric_nodes[nx] = friction_mid;
const double spring = k_pump * uN;
const double visc = c_pump * vN;
const double fric = (pump_friction + 0.65 * stuffing_friction) * solver_signum(vN) + friction_mid;
const double pump_force = spring + visc + fric - side_mid;
u_next[nx] = u_next[nx - 1] + (pump_force * dx) / (E * area);
u_next[0] = surf_amp * sin(omega * (t + dt));
const double e01 = segment_mean(inputs->modulus_pa, 0, nx);
const double a01 = segment_mean(inputs->area_m2, 0, nx);
const double polished_tension = E * area * (u_curr[1] - u_curr[0]) / dx;
const double polished = baseline + polished_tension + stuffing_friction * solver_signum(v0);
const double downhole = baseline + pump_force;
outputs->position[n] = u_curr[0];
outputs->polished_load[n] = polished;
outputs->downhole_load[n] = downhole;
outputs->pump_position_m[n] = u_curr[nx];
outputs->polished_stress_pa[n] = polished_tension / fmax(a01, 1e-12);
const double side_top = solver_compute_side_load_node(inputs, polished_tension, 0, dx);
side_nodes[0] = side_top;
fric_nodes[0] = solver_compute_friction_node(inputs, side_top, v0, 0);
outputs->side_load_profile_n[n] = side_top;
(void)e01;
solver_update_output_ranges(outputs, polished, downhole);
for (int i = 0; i <= nx; i++) {
u_prev[i] = u_curr[i];
u_curr[i] = u_next[i];
}
}
outputs->gas_interference =
(inputs->pump_fillage_option == 2 || (inputs->percent_pump_fillage > 0.0 && inputs->percent_pump_fillage < 88.0))
? 1
: 0;
for (int i = 0; i < outputs->point_count; i++) {
if (i == 0) {
outputs->pump_velocity_m_s[i] = 0.0;
} else {
outputs->pump_velocity_m_s[i] = (outputs->pump_position_m[i] - outputs->pump_position_m[i - 1]) / dt;
}
}
if (outputs->point_count > 1) {
outputs->pump_velocity_m_s[0] = outputs->pump_velocity_m_s[1];
}
for (int i = 0; i < outputs->point_count; i++) {
solver_valve_state_step(inputs, outputs, i, outputs->pump_position_m[i], outputs->pump_velocity_m_s[i], outputs->downhole_load[i]);
}
solver_fill_profiles(inputs, outputs, nx + 1, rod_length, side_nodes, fric_nodes);
if (inputs->enable_fourier_baseline) {
(void)solver_compute_fourier_baseline(inputs, outputs);
}
return 0;
}
int solver_run(const SolverInputs *inputs, SolverOutputs *outputs) {
return solver_run_fdm(inputs, outputs);
}

View File

@@ -0,0 +1,117 @@
#include "solver_internal.h"
#include <math.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
double solver_clamp(double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
double solver_signum(double v) {
if (v > 0.0) return 1.0;
if (v < 0.0) return -1.0;
return 0.0;
}
void solver_add_warning(SolverOutputs *outputs, const char *msg) {
if (outputs->warning_count >= SOLVER_MAX_WARNINGS) {
return;
}
strncpy(outputs->warnings[outputs->warning_count], msg, SOLVER_WARNING_LEN - 1);
outputs->warnings[outputs->warning_count][SOLVER_WARNING_LEN - 1] = '\0';
outputs->warning_count += 1;
}
void solver_init_output_ranges(SolverOutputs *outputs) {
outputs->max_polished_load = -1e99;
outputs->min_polished_load = 1e99;
outputs->max_downhole_load = -1e99;
outputs->min_downhole_load = 1e99;
}
void solver_update_output_ranges(SolverOutputs *outputs, double polished, double downhole) {
if (polished > outputs->max_polished_load) outputs->max_polished_load = polished;
if (polished < outputs->min_polished_load) outputs->min_polished_load = polished;
if (downhole > outputs->max_downhole_load) outputs->max_downhole_load = downhole;
if (downhole < outputs->min_downhole_load) outputs->min_downhole_load = downhole;
}
double solver_input_or_default(double value, double fallback) {
return (value > 0.0) ? value : fallback;
}
double solver_compute_side_load_node(const SolverInputs *inputs, double tension_n, int node_idx, double ds) {
if (!inputs) return 0.0;
const int i = (node_idx < 0) ? 0 : ((node_idx >= SOLVER_MAX_NODES) ? SOLVER_MAX_NODES - 1 : node_idx);
const double kappa = fmax(inputs->node_curvature[i], 0.0);
const double inc = fabs(inputs->node_inc_rad[i]);
const double rho = fmax(inputs->density_kg_m3[i], 1.0);
const double area = fmax(inputs->area_m2[i], 1e-10);
double buoyed_weight = fmax((rho - inputs->fluid_density_kg_m3) * area * inputs->gravity, 0.0);
if (inputs->weight_n_per_m[i] > 0.0) {
buoyed_weight = fmax(inputs->weight_n_per_m[i] - inputs->fluid_density_kg_m3 * area * inputs->gravity, 0.0);
}
const double guide_weight = fmax(inputs->guide_weight_n_per_m[i], 0.0);
const double sinker_area = M_PI * 0.25 * inputs->sinker_bar_diameter_m * inputs->sinker_bar_diameter_m;
const double sinker_weight = (inputs->sinker_bar_length_m > 0.0)
? fmax((7850.0 - inputs->fluid_density_kg_m3) * sinker_area * inputs->gravity, 0.0)
: 0.0;
/* Lukasiewicz-inspired normal force combination: curvature tension + gravity/inclination lateral component */
const double normal = fabs(tension_n) * kappa + (buoyed_weight + guide_weight + sinker_weight) * sin(inc) * fmax(ds, 1e-6);
return fmax(normal, 0.0);
}
double solver_compute_friction_node(const SolverInputs *inputs, double side_load_n, double velocity_m_s, int node_idx) {
if (!inputs) return 0.0;
const int i = (node_idx < 0) ? 0 : ((node_idx >= SOLVER_MAX_NODES) ? SOLVER_MAX_NODES - 1 : node_idx);
const double base_mu = solver_clamp(inputs->rod_friction_coefficient, 0.0, 1.5);
const double guide_scale = (inputs->molded_guide_mu_scale + inputs->wheeled_guide_mu_scale + inputs->other_guide_mu_scale) / 3.0;
const double inc_scale = 1.0 + 0.5 * fabs(sin(inputs->node_inc_rad[i]));
const double mts_scale = inputs->mts_n[i] > 0.0 ? solver_clamp(inputs->mts_n[i] / 8.0e5, 0.5, 1.5) : 1.0;
const double mu = base_mu * solver_clamp(guide_scale, 0.1, 3.0) * inc_scale * mts_scale;
return mu * fmax(side_load_n, 0.0) * solver_signum(velocity_m_s);
}
void solver_fill_profiles(const SolverInputs *inputs, SolverOutputs *outputs, int node_count, double rod_length_m,
const double *side_load_nodes, const double *friction_nodes) {
if (!inputs || !outputs) return;
const int n = (node_count > SOLVER_MAX_NODES) ? SOLVER_MAX_NODES : node_count;
outputs->profile_node_count = n;
for (int i = 0; i < n; i++) {
outputs->profile_md_m[i] = (n > 1) ? rod_length_m * (double)i / (double)(n - 1) : 0.0;
outputs->profile_curvature_1pm[i] = inputs->node_curvature[i];
outputs->profile_inclination_rad[i] = inputs->node_inc_rad[i];
outputs->profile_azimuth_rad[i] = inputs->node_azi_rad[i];
outputs->profile_side_load_n[i] = side_load_nodes ? side_load_nodes[i] : 0.0;
outputs->profile_friction_n[i] = friction_nodes ? friction_nodes[i] : 0.0;
}
}
void solver_valve_state_step(const SolverInputs *inputs, SolverOutputs *outputs, int step_idx, double pump_position_m,
double pump_velocity_m_s, double downhole_load_n) {
if (!inputs || !outputs) return;
if (step_idx < 0 || step_idx >= SOLVER_MAX_POINTS) return;
const double area = M_PI * fmax(inputs->pump_diameter_m, 0.02) * fmax(inputs->pump_diameter_m, 0.02) * 0.25;
const double hydro = inputs->pump_intake_pressure_pa + inputs->fluid_density_kg_m3 * inputs->gravity * fmax(inputs->pump_depth, 0.0);
const double piston_pressure = hydro + downhole_load_n / fmax(area, 1e-6);
const double discharge_pressure = hydro + 0.5 * fabs(downhole_load_n) / fmax(area, 1e-6);
const int standing_open = (piston_pressure < hydro * 1.02) ? 1 : 0;
const int traveling_open = (pump_velocity_m_s < 0.0 && piston_pressure > discharge_pressure * 0.98) ? 1 : 0;
outputs->valve_standing_open[step_idx] = standing_open;
outputs->valve_traveling_open[step_idx] = traveling_open;
const double stroke_norm = solver_clamp(fabs(pump_position_m) / fmax(fabs(pump_position_m) + 1e-6, 1e-6), 0.0, 1.0);
const double gas_base = solver_clamp(1.0 - inputs->percent_pump_fillage / 100.0, 0.0, 0.9);
double gas = gas_base + 0.2 * (1.0 - stroke_norm) + ((standing_open && traveling_open) ? 0.05 : 0.0);
gas = solver_clamp(gas, 0.0, 0.98);
outputs->gas_fraction[step_idx] = gas;
outputs->chamber_pressure_pa[step_idx] = piston_pressure * (1.0 + 0.25 * gas);
if (gas > 0.35) outputs->gas_interference = 1;
}

View File

@@ -0,0 +1,239 @@
#include "solver.h"
#include "solver_internal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define FDM_NX 48
static double mean_series(const double *v, int n) {
if (n <= 0) {
return 0.0;
}
double s = 0.0;
for (int i = 0; i < n; i++) {
s += v[i];
}
return s / (double)n;
}
static double segment_mean(const double *a, int i, int max_i) {
const int j = i + 1 > max_i ? max_i : i + 1;
return 0.5 * (a[i] + a[j]);
}
int solver_run_diagnostic_fdm(const SolverInputs *inputs_in, SolverOutputs *outputs) {
if (inputs_in == NULL || outputs == NULL) {
return -1;
}
SolverInputs inputs_local = *inputs_in;
SolverInputs *inputs = &inputs_local;
memset(outputs, 0, sizeof(SolverOutputs));
solver_add_warning(outputs, "Diagnostic FDM: surface card BC (Everitt & Jennings stencil, MATH_SPEC.md)");
const int point_count = inputs->surface_count;
if (point_count < 40) {
return -2;
}
outputs->point_count = point_count;
const double pumping_speed = inputs->pumping_speed > 0.0 ? inputs->pumping_speed : 5.0;
const double period = 60.0 / pumping_speed;
double dt = period / (double)(point_count - 1);
if (inputs->surface_has_time && point_count > 1) {
dt = (inputs->surface_time_s[point_count - 1] - inputs->surface_time_s[0]) / (double)(point_count - 1);
if (dt <= 0.0 || dt > period * 2.0) {
dt = period / (double)(point_count - 1);
}
}
const double depth = inputs->pump_depth > 0.0 ? inputs->pump_depth : 1000.0;
const double anchor = inputs->tubing_anchor_location > 0.0 ? inputs->tubing_anchor_location : depth * 0.8;
const double rod_length = solver_clamp(depth - anchor, 250.0, 3500.0);
const int nx = FDM_NX;
const int nodes = nx + 1;
const double dx = rod_length / (double)nx;
if (!inputs->has_variable_rod || inputs->rod_node_count != nodes) {
inputs->has_variable_rod = 1;
inputs->rod_node_count = nodes;
const double E = 2.05e11;
const double rho = 7850.0;
const double taper = solver_input_or_default(inputs->taper_factor, 1.0);
const double area = solver_clamp((0.00042 + inputs->rod_friction_coefficient * 0.00008) * taper, 0.00025, 0.0009);
for (int i = 0; i < nodes; i++) {
inputs->area_m2[i] = area;
inputs->modulus_pa[i] = E;
inputs->density_kg_m3[i] = rho;
}
}
solver_trajectory_preprocess(inputs, nx, rod_length);
double e_ref = 0.0;
double rho_ref = 0.0;
for (int i = 0; i < nodes; i++) {
e_ref += inputs->modulus_pa[i];
rho_ref += inputs->density_kg_m3[i];
}
e_ref /= (double)nodes;
rho_ref /= (double)nodes;
const double a = sqrt(e_ref / fmax(rho_ref, 1e-6));
const double cfl = (a * dt) / dx;
const double cfl_eff = (cfl > 0.95) ? 0.95 : cfl;
outputs->max_cfl = cfl;
outputs->wave_speed_ref_m_s = a;
const double r = cfl_eff * cfl_eff;
const double gamma = solver_clamp(0.08 + inputs->rod_friction_coefficient * 0.25, 0.05, 0.3);
const double damping = gamma * dt;
double baseline = depth * 2.05 + 3900.0;
for (int i = 0; i < nx; i++) {
const double rho_i = inputs->density_kg_m3[i];
const double A_i = segment_mean(inputs->area_m2, i, nodes - 1);
const double inc = 0.5 * (inputs->node_inc_rad[i] + inputs->node_inc_rad[i + 1]);
baseline += 0.5 * (rho_i - inputs->fluid_density_kg_m3) * A_i * inputs->gravity * cos(inc) * dx;
}
const double k_pump = solver_clamp(1.8e5 + depth * 180.0, 1.2e5, 9.0e5);
const double c_pump = solver_clamp(220.0 + inputs->rod_friction_coefficient * 800.0, 120.0, 1200.0);
double kappa_mean = 0.0;
for (int i = 0; i < nodes; i++) {
kappa_mean += inputs->node_curvature[i];
}
kappa_mean /= (double)nodes;
const double mu_geo =
inputs->rod_friction_coefficient *
(inputs->molded_guide_mu_scale + inputs->wheeled_guide_mu_scale + inputs->other_guide_mu_scale) / 3.0;
double friction_geom = 1.0 + solver_clamp(mu_geo * kappa_mean * rod_length * 0.08, 0.0, 1.2);
if (!inputs->geometry_valid) {
friction_geom = solver_clamp(inputs->trajectory_friction_multiplier, 1.0, 1.8);
}
const double pump_friction = solver_clamp(inputs->pump_friction * friction_geom, 0.0, 6000.0);
const double stuffing_friction = solver_clamp(inputs->stuffing_box_friction * friction_geom, 0.0, 2800.0);
double side_nodes[SOLVER_MAX_NODES];
double fric_nodes[SOLVER_MAX_NODES];
memset(side_nodes, 0, sizeof(side_nodes));
memset(fric_nodes, 0, sizeof(fric_nodes));
double u_prev[SOLVER_MAX_NODES];
double u_curr[SOLVER_MAX_NODES];
double u_next[SOLVER_MAX_NODES];
memset(u_prev, 0, sizeof(u_prev));
memset(u_curr, 0, sizeof(u_curr));
memset(u_next, 0, sizeof(u_next));
const double pos_mean = mean_series(inputs->surface_position_m, point_count);
const double load_mean = mean_series(inputs->surface_load_n, point_count);
solver_init_output_ranges(outputs);
for (int n = 0; n < point_count; n++) {
const double pos = inputs->surface_position_m[n] - pos_mean;
const double load = inputs->surface_load_n[n] - load_mean;
u_curr[0] = pos;
const double e01 = segment_mean(inputs->modulus_pa, 0, nodes - 1);
const double a01 = segment_mean(inputs->area_m2, 0, nodes - 1);
u_curr[1] = u_curr[0] + (load * dx) / (e01 * a01 + 1e-30);
if (n == 0) {
for (int i = 2; i <= nx; i++) {
const double ratio = 1.0 - (double)i / (double)nx;
u_curr[i] = u_curr[1] * ratio;
u_prev[i] = u_curr[i];
}
u_prev[0] = u_curr[0];
u_prev[1] = u_curr[1];
}
for (int i = 1; i < nx; i++) {
const double lap = u_curr[i + 1] - 2.0 * u_curr[i] + u_curr[i - 1];
const double v_i = (u_curr[i] - u_prev[i]) / dt;
const double e_i = segment_mean(inputs->modulus_pa, i - 1, nodes - 1);
const double a_i = segment_mean(inputs->area_m2, i - 1, nodes - 1);
const double t_i = e_i * a_i * (u_curr[i] - u_curr[i - 1]) / dx;
const double side_i = solver_compute_side_load_node(inputs, t_i, i, dx);
const double fric_i = solver_compute_friction_node(inputs, side_i, v_i, i);
side_nodes[i] = side_i;
fric_nodes[i] = fric_i;
const double body = -(side_i + fric_i) * dx / fmax(e_i * a_i, 1e-6);
u_next[i] = (2.0 - damping) * u_curr[i] - (1.0 - damping) * u_prev[i] + r * lap + body;
}
const double e_end = segment_mean(inputs->modulus_pa, nx - 1, nodes - 1);
const double a_end = segment_mean(inputs->area_m2, nx - 1, nodes - 1);
const double vN = (u_curr[nx] - u_prev[nx]) / dt;
const double tension_end = e_end * a_end * (u_curr[nx] - u_curr[nx - 1]) / dx;
const double side_mid = solver_compute_side_load_node(inputs, tension_end, nx, dx);
const double fric_mid = solver_compute_friction_node(inputs, side_mid, vN, nx);
side_nodes[nx] = side_mid;
fric_nodes[nx] = fric_mid;
const double spring = k_pump * u_curr[nx];
const double visc = c_pump * vN;
const double fric = (pump_friction + 0.65 * stuffing_friction) * solver_signum(vN) + fric_mid;
const double pump_force = spring + visc + fric - side_mid;
u_next[nx] = u_next[nx - 1] + (pump_force * dx) / (e_end * a_end + 1e-30);
const int next_idx = (n + 1) % point_count;
const double pos_next = inputs->surface_position_m[next_idx] - pos_mean;
const double load_next = inputs->surface_load_n[next_idx] - load_mean;
u_next[0] = pos_next;
u_next[1] = u_next[0] + (load_next * dx) / (e01 * a01 + 1e-30);
const double v0 = (u_curr[0] - u_prev[0]) / dt;
const double t_surf = (e01 * a01 * (u_curr[1] - u_curr[0])) / dx;
const double polished = baseline + t_surf + stuffing_friction * solver_signum(v0);
const double downhole = baseline + pump_force;
outputs->position[n] = u_curr[0];
outputs->polished_load[n] = polished;
outputs->downhole_load[n] = downhole;
outputs->pump_position_m[n] = u_curr[nx];
outputs->polished_stress_pa[n] = t_surf / fmax(a01, 1e-12);
const double side_top = solver_compute_side_load_node(inputs, t_surf, 0, dx);
side_nodes[0] = side_top;
fric_nodes[0] = solver_compute_friction_node(inputs, side_top, v0, 0);
outputs->side_load_profile_n[n] = side_top;
solver_update_output_ranges(outputs, polished, downhole);
for (int i = 0; i <= nx; i++) {
u_prev[i] = u_curr[i];
u_curr[i] = u_next[i];
}
}
outputs->gas_interference =
(inputs->pump_fillage_option == 2 || (inputs->percent_pump_fillage > 0.0 && inputs->percent_pump_fillage < 88.0))
? 1
: 0;
for (int i = 0; i < point_count; i++) {
if (i == 0) {
outputs->pump_velocity_m_s[i] = 0.0;
} else {
outputs->pump_velocity_m_s[i] = (outputs->pump_position_m[i] - outputs->pump_position_m[i - 1]) / dt;
}
}
if (point_count > 1) {
outputs->pump_velocity_m_s[0] = outputs->pump_velocity_m_s[1];
}
for (int i = 0; i < point_count; i++) {
solver_valve_state_step(inputs, outputs, i, outputs->pump_position_m[i], outputs->pump_velocity_m_s[i], outputs->downhole_load[i]);
}
solver_fill_profiles(inputs, outputs, nodes, rod_length, side_nodes, fric_nodes);
if (inputs->enable_fourier_baseline) {
(void)solver_compute_fourier_baseline(inputs, outputs);
}
return 0;
}

432
solver-c/src/solver_fea.c Normal file
View File

@@ -0,0 +1,432 @@
#include "solver.h"
#include "solver_internal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define FEA_ELEMENTS 24
#define MAX_FEA_NODES 65
static int solve_linear_system(int n, double A[MAX_FEA_NODES][MAX_FEA_NODES], double b[MAX_FEA_NODES], double x[MAX_FEA_NODES]) {
for (int i = 0; i < n; i++) {
int pivot = i;
double max_val = fabs(A[i][i]);
for (int k = i + 1; k < n; k++) {
double val = fabs(A[k][i]);
if (val > max_val) {
max_val = val;
pivot = k;
}
}
if (max_val < 1e-18) {
return -1;
}
if (pivot != i) {
for (int j = i; j < n; j++) {
double tmp = A[i][j];
A[i][j] = A[pivot][j];
A[pivot][j] = tmp;
}
double bt = b[i];
b[i] = b[pivot];
b[pivot] = bt;
}
double diag = A[i][i];
for (int j = i; j < n; j++) {
A[i][j] /= diag;
}
b[i] /= diag;
for (int k = i + 1; k < n; k++) {
double factor = A[k][i];
for (int j = i; j < n; j++) {
A[k][j] -= factor * A[i][j];
}
b[k] -= factor * b[i];
}
}
for (int i = n - 1; i >= 0; i--) {
double sum = b[i];
for (int j = i + 1; j < n; j++) {
sum -= A[i][j] * x[j];
}
x[i] = sum;
}
return 0;
}
static void fea_assemble_system(const SolverInputs *inputs, int nodes, int elements, double dx, double K[MAX_FEA_NODES][MAX_FEA_NODES],
double M[MAX_FEA_NODES][MAX_FEA_NODES], double *alpha_rayleigh, double *beta_rayleigh) {
memset(K, 0, sizeof(double) * MAX_FEA_NODES * MAX_FEA_NODES);
memset(M, 0, sizeof(double) * MAX_FEA_NODES * MAX_FEA_NODES);
double rho_bar = 0.0;
double e_bar = 0.0;
for (int i = 0; i < nodes; i++) {
rho_bar += inputs->density_kg_m3[i];
e_bar += inputs->modulus_pa[i];
}
rho_bar /= (double)nodes;
e_bar /= (double)nodes;
for (int e = 0; e < elements; e++) {
const int i = e;
const int j = e + 1;
const double Ee = 0.5 * (inputs->modulus_pa[i] + inputs->modulus_pa[j]);
const double Ae = 0.5 * (inputs->area_m2[i] + inputs->area_m2[j]);
const double rhoe = 0.5 * (inputs->density_kg_m3[i] + inputs->density_kg_m3[j]);
const double ke = Ee * Ae / dx;
const double me = rhoe * Ae * dx / 6.0;
K[i][i] += ke;
K[i][j] -= ke;
K[j][i] -= ke;
K[j][j] += ke;
M[i][i] += 2.0 * me;
M[i][j] += me;
M[j][i] += me;
M[j][j] += 2.0 * me;
}
const double L = dx * (double)elements;
const double nu = solver_clamp(inputs->non_dim_damping, 0.05, 1.5);
const double omega1 = M_PI * sqrt(e_bar / fmax(rho_bar, 1e-9)) / fmax(L, 1.0);
const double zeta = solver_clamp(0.02 + inputs->upstroke_damping * 0.05 + nu * 0.03, 0.01, 0.25);
*alpha_rayleigh = 2.0 * zeta * omega1;
*beta_rayleigh = zeta / (2.0 * fmax(omega1, 1e-6));
}
static int fea_newmark_step(const SolverInputs *inputs, int nodes, int elements, double dx, double dt, double k_pump, double c_pump,
double pump_friction, double stuffing_friction, double u0, double v0, double bottom_force_extra, double *u,
double *v, double *a_vec, double *polished_tension_out, double *downhole_tension_out) {
(void)elements;
double K[MAX_FEA_NODES][MAX_FEA_NODES];
double M[MAX_FEA_NODES][MAX_FEA_NODES];
double C[MAX_FEA_NODES][MAX_FEA_NODES];
double alpha_r = 0.0;
double beta_r = 0.0;
fea_assemble_system(inputs, nodes, elements, dx, K, M, &alpha_r, &beta_r);
for (int i = 0; i < nodes; i++) {
for (int j = 0; j < nodes; j++) {
C[i][j] = alpha_r * M[i][j] + beta_r * K[i][j];
}
}
K[nodes - 1][nodes - 1] += k_pump;
C[nodes - 1][nodes - 1] += c_pump;
const double beta_nm = 0.25;
const double gamma_nm = 0.5;
const double a0 = 1.0 / (beta_nm * dt * dt);
const double a1 = gamma_nm / (beta_nm * dt);
const double a2 = 1.0 / (beta_nm * dt);
const double a3 = (1.0 / (2.0 * beta_nm)) - 1.0;
const double a4 = (gamma_nm / beta_nm) - 1.0;
const double a5 = dt * ((gamma_nm / (2.0 * beta_nm)) - 1.0);
const double friction_force = (pump_friction + 0.65 * stuffing_friction) * solver_signum(v[nodes - 1]);
double F[MAX_FEA_NODES];
memset(F, 0, sizeof(F));
for (int i = 1; i < nodes; i++) {
const double tension_i = (i > 0) ? (0.5 * (inputs->modulus_pa[i] + inputs->modulus_pa[i - 1]) *
0.5 * (inputs->area_m2[i] + inputs->area_m2[i - 1]) *
(u[i] - u[i - 1]) / fmax(dx, 1e-9))
: 0.0;
const double side_i = solver_compute_side_load_node(inputs, tension_i, i, dx);
const double fric_i = solver_compute_friction_node(inputs, side_i, v[i], i);
F[i] -= side_i + fric_i;
}
F[nodes - 1] -= friction_force + bottom_force_extra;
const int free_nodes = nodes - 1;
double Kff[MAX_FEA_NODES][MAX_FEA_NODES];
double Mff[MAX_FEA_NODES][MAX_FEA_NODES];
double Cff[MAX_FEA_NODES][MAX_FEA_NODES];
for (int i = 0; i < free_nodes; i++) {
for (int j = 0; j < free_nodes; j++) {
Kff[i][j] = K[i + 1][j + 1];
Mff[i][j] = M[i + 1][j + 1];
Cff[i][j] = C[i + 1][j + 1];
}
}
double rhs[MAX_FEA_NODES];
double Keff[MAX_FEA_NODES][MAX_FEA_NODES];
memset(rhs, 0, sizeof(rhs));
memset(Keff, 0, sizeof(Keff));
for (int i = 0; i < free_nodes; i++) {
const int gi = i + 1;
rhs[i] = F[gi] - K[gi][0] * u0 - C[gi][0] * v0;
for (int j = 0; j < free_nodes; j++) {
Keff[i][j] = Kff[i][j] + a0 * Mff[i][j] + a1 * Cff[i][j];
rhs[i] += Mff[i][j] * (a0 * u[j + 1] + a2 * v[j + 1] + a3 * a_vec[j + 1]);
rhs[i] += Cff[i][j] * (a1 * u[j + 1] + a4 * v[j + 1] + a5 * a_vec[j + 1]);
}
}
double u_new[MAX_FEA_NODES];
memset(u_new, 0, sizeof(u_new));
if (solve_linear_system(free_nodes, Keff, rhs, u_new) != 0) {
return -2;
}
double a_new[MAX_FEA_NODES];
double v_new[MAX_FEA_NODES];
memset(a_new, 0, sizeof(a_new));
memset(v_new, 0, sizeof(v_new));
double old_u[MAX_FEA_NODES];
double old_v[MAX_FEA_NODES];
double old_a[MAX_FEA_NODES];
memcpy(old_u, u, sizeof(old_u));
memcpy(old_v, v, sizeof(old_v));
memcpy(old_a, a_vec, sizeof(old_a));
u[0] = u0;
v[0] = v0;
for (int i = 0; i < free_nodes; i++) {
const int gi = i + 1;
u[gi] = u_new[i];
a_new[gi] = a0 * (u[gi] - old_u[gi]) - a2 * old_v[gi] - a3 * old_a[gi];
v_new[gi] = old_v[gi] + dt * ((1.0 - gamma_nm) * old_a[gi] + gamma_nm * a_new[gi]);
}
for (int gi = 1; gi < nodes; gi++) {
a_vec[gi] = a_new[gi];
v[gi] = v_new[gi];
}
const double e01 = 0.5 * (inputs->modulus_pa[0] + inputs->modulus_pa[1]);
const double a01 = 0.5 * (inputs->area_m2[0] + inputs->area_m2[1]);
const double polished_tension = e01 * a01 * (u[1] - u[0]) / dx;
const double e_end = 0.5 * (inputs->modulus_pa[nodes - 1] + inputs->modulus_pa[nodes - 2]);
const double a_end = 0.5 * (inputs->area_m2[nodes - 1] + inputs->area_m2[nodes - 2]);
const double downhole_tension =
e_end * a_end * (u[nodes - 1] - u[nodes - 2]) / dx + k_pump * u[nodes - 1] + c_pump * v[nodes - 1] + friction_force + bottom_force_extra;
*polished_tension_out = polished_tension;
*downhole_tension_out = downhole_tension;
return 0;
}
int solver_run_fea(const SolverInputs *inputs_in, SolverOutputs *outputs) {
if (inputs_in == NULL || outputs == NULL) {
return -1;
}
SolverInputs inputs_local = *inputs_in;
SolverInputs *inputs = &inputs_local;
memset(outputs, 0, sizeof(SolverOutputs));
outputs->point_count = 200;
solver_add_warning(outputs, "FEA: dynamic 1D bar, Newmark-beta, Rayleigh damping, variable EA (Eisner-style bar FEM)");
const double spm = solver_input_or_default(inputs->pumping_speed, 1.0);
const double depth = solver_input_or_default(inputs->pump_depth, 1000.0);
const double anchor = solver_input_or_default(inputs->tubing_anchor_location, depth * 0.8);
const double rod_length = solver_clamp(depth - anchor, 250.0, 3500.0);
const int elements = FEA_ELEMENTS;
const int nodes = elements + 1;
const double dx = rod_length / (double)elements;
const double period = 60.0 / spm;
const double dt = period / (double)(outputs->point_count - 1);
if (!inputs->has_variable_rod || inputs->rod_node_count < nodes) {
inputs->has_variable_rod = 1;
inputs->rod_node_count = nodes;
const double E = 2.05e11;
const double rho = 7850.0;
const double taper = solver_input_or_default(inputs->taper_factor, 1.0);
const double area = solver_clamp((0.00042 + inputs->rod_friction_coefficient * 0.00008) * taper, 0.00025, 0.0009);
for (int i = 0; i < nodes; i++) {
inputs->area_m2[i] = area;
inputs->modulus_pa[i] = E;
inputs->density_kg_m3[i] = rho;
}
}
solver_trajectory_preprocess(inputs, elements, rod_length);
double kappa_mean = 0.0;
for (int i = 0; i < nodes; i++) {
kappa_mean += inputs->node_curvature[i];
}
kappa_mean /= (double)nodes;
const double mu_geo =
inputs->rod_friction_coefficient *
(inputs->molded_guide_mu_scale + inputs->wheeled_guide_mu_scale + inputs->other_guide_mu_scale) / 3.0;
double friction_geom = 1.0 + solver_clamp(mu_geo * kappa_mean * rod_length * 0.08, 0.0, 1.2);
if (!inputs->geometry_valid) {
friction_geom = solver_clamp(inputs->trajectory_friction_multiplier, 1.0, 1.8);
}
const double pump_friction = solver_clamp(inputs->pump_friction * friction_geom, 0.0, 7000.0);
const double stuffing_friction = solver_clamp(inputs->stuffing_box_friction * friction_geom, 0.0, 3500.0);
double side_nodes[SOLVER_MAX_NODES];
double fric_nodes[SOLVER_MAX_NODES];
memset(side_nodes, 0, sizeof(side_nodes));
memset(fric_nodes, 0, sizeof(fric_nodes));
const double k_pump = solver_clamp(1.8e5 + depth * 180.0, 1.2e5, 9.0e5);
const double c_pump = solver_clamp(220.0 + inputs->rod_friction_coefficient * 800.0, 120.0, 1200.0);
double baseline = depth * 2.05 + 3900.0;
for (int i = 0; i < elements; i++) {
const double rho_i = inputs->density_kg_m3[i];
const double A_i = 0.5 * (inputs->area_m2[i] + inputs->area_m2[i + 1]);
const double inc = 0.5 * (inputs->node_inc_rad[i] + inputs->node_inc_rad[i + 1]);
baseline += 0.5 * (rho_i - inputs->fluid_density_kg_m3) * A_i * inputs->gravity * cos(inc) * dx;
}
double u[MAX_FEA_NODES];
double v[MAX_FEA_NODES];
double a_vec[MAX_FEA_NODES];
memset(u, 0, sizeof(u));
memset(v, 0, sizeof(v));
memset(a_vec, 0, sizeof(a_vec));
const double surf_amp = solver_clamp(depth * 0.00045, 0.4, 2.6);
const double omega = 2.0 * M_PI / period;
solver_init_output_ranges(outputs);
if (inputs->workflow == 1 && inputs->surface_count >= 40) {
double pos_acc = 0.0;
double load_acc = 0.0;
for (int i = 0; i < inputs->surface_count; i++) {
pos_acc += inputs->surface_position_m[i];
load_acc += inputs->surface_load_n[i];
}
const double pm = pos_acc / (double)inputs->surface_count;
const double lm = load_acc / (double)inputs->surface_count;
const int steps = inputs->surface_count;
double dtd = dt;
if (inputs->surface_has_time && steps > 1) {
dtd = (inputs->surface_time_s[steps - 1] - inputs->surface_time_s[0]) / (double)(steps - 1);
}
for (int n = 0; n < steps; n++) {
const double u0 = inputs->surface_position_m[n] - pm;
const double v0 = (n + 1 < steps)
? (inputs->surface_position_m[n + 1] - inputs->surface_position_m[n]) / dtd
: (inputs->surface_position_m[0] - inputs->surface_position_m[steps - 1]) / dtd;
const double target_tension =
(inputs->surface_load_n[n] - lm) - baseline - stuffing_friction * solver_signum(v0);
double u_snap[MAX_FEA_NODES];
double v_snap[MAX_FEA_NODES];
double a_snap[MAX_FEA_NODES];
memcpy(u_snap, u, sizeof(u_snap));
memcpy(v_snap, v, sizeof(v_snap));
memcpy(a_snap, a_vec, sizeof(a_snap));
double lo = -1.2e6;
double hi = 1.2e6;
double fb = 0.0;
double pol_t = 0.0;
double dh_t = 0.0;
for (int it = 0; it < 24; it++) {
fb = 0.5 * (lo + hi);
memcpy(u, u_snap, sizeof(u_snap));
memcpy(v, v_snap, sizeof(v_snap));
memcpy(a_vec, a_snap, sizeof(a_snap));
if (fea_newmark_step(inputs, nodes, elements, dx, dtd, k_pump, c_pump, pump_friction, stuffing_friction, u0, v0, fb, u, v,
a_vec, &pol_t, &dh_t) != 0) {
return -3;
}
const double err = pol_t - target_tension;
if (fabs(err) < 40.0) {
break;
}
if (err > 0) {
hi = fb;
} else {
lo = fb;
}
}
const double polished = baseline + pol_t + stuffing_friction * solver_signum(v0);
const double downhole = baseline + dh_t;
outputs->position[n] = u0;
outputs->polished_load[n] = polished;
outputs->downhole_load[n] = downhole;
outputs->pump_position_m[n] = u[nodes - 1];
outputs->polished_stress_pa[n] = pol_t / fmax(0.5 * (inputs->area_m2[0] + inputs->area_m2[1]), 1e-12);
const double side_top = solver_compute_side_load_node(inputs, pol_t, 0, dx);
const double vloc = (n > 0) ? (outputs->pump_position_m[n] - outputs->pump_position_m[n - 1]) / dtd : v0;
outputs->side_load_profile_n[n] = side_top;
side_nodes[0] = side_top;
fric_nodes[0] = solver_compute_friction_node(inputs, side_top, vloc, 0);
side_nodes[nodes - 1] = solver_compute_side_load_node(inputs, dh_t, nodes - 1, dx);
fric_nodes[nodes - 1] = solver_compute_friction_node(inputs, side_nodes[nodes - 1], vloc, nodes - 1);
solver_update_output_ranges(outputs, polished, downhole);
solver_valve_state_step(inputs, outputs, n, outputs->pump_position_m[n], vloc, downhole);
}
outputs->point_count = steps;
for (int i = 0; i < steps; i++) {
if (i == 0) {
outputs->pump_velocity_m_s[i] = 0.0;
} else {
outputs->pump_velocity_m_s[i] = (outputs->pump_position_m[i] - outputs->pump_position_m[i - 1]) / dtd;
}
}
if (steps > 1) {
outputs->pump_velocity_m_s[0] = outputs->pump_velocity_m_s[1];
}
outputs->gas_interference =
(inputs->pump_fillage_option == 2 || (inputs->percent_pump_fillage > 0.0 && inputs->percent_pump_fillage < 88.0)) ? 1 : 0;
solver_fill_profiles(inputs, outputs, nodes, rod_length, side_nodes, fric_nodes);
if (inputs->enable_fourier_baseline) {
(void)solver_compute_fourier_baseline(inputs, outputs);
}
return 0;
}
for (int n = 0; n < outputs->point_count; n++) {
const double t = n * dt;
const double u0 = surf_amp * sin(omega * t);
const double v0 = surf_amp * omega * cos(omega * t);
double pol = 0.0;
double dhh = 0.0;
if (fea_newmark_step(inputs, nodes, elements, dx, dt, k_pump, c_pump, pump_friction, stuffing_friction, u0, v0, 0.0, u, v, a_vec,
&pol, &dhh) != 0) {
solver_add_warning(outputs, "FEA linear solve singular; aborting");
return -2;
}
const double polished = baseline + pol + stuffing_friction * solver_signum(v0);
const double downhole = baseline + dhh;
outputs->position[n] = u0;
outputs->polished_load[n] = polished;
outputs->downhole_load[n] = downhole;
outputs->pump_position_m[n] = u[nodes - 1];
outputs->polished_stress_pa[n] = pol / fmax(0.5 * (inputs->area_m2[0] + inputs->area_m2[1]), 1e-12);
const double side_top = solver_compute_side_load_node(inputs, pol, 0, dx);
outputs->side_load_profile_n[n] = side_top;
side_nodes[0] = side_top;
fric_nodes[0] = solver_compute_friction_node(inputs, side_top, v0, 0);
side_nodes[nodes - 1] = solver_compute_side_load_node(inputs, dhh, nodes - 1, dx);
fric_nodes[nodes - 1] = solver_compute_friction_node(inputs, side_nodes[nodes - 1], v0, nodes - 1);
solver_update_output_ranges(outputs, polished, downhole);
solver_valve_state_step(inputs, outputs, n, outputs->pump_position_m[n], v0, downhole);
}
for (int i = 0; i < outputs->point_count; i++) {
if (i == 0) {
outputs->pump_velocity_m_s[i] = 0.0;
} else {
outputs->pump_velocity_m_s[i] = (outputs->pump_position_m[i] - outputs->pump_position_m[i - 1]) / dt;
}
}
if (outputs->point_count > 1) {
outputs->pump_velocity_m_s[0] = outputs->pump_velocity_m_s[1];
}
outputs->gas_interference =
(inputs->pump_fillage_option == 2 || (inputs->percent_pump_fillage > 0.0 && inputs->percent_pump_fillage < 88.0)) ? 1 : 0;
solver_fill_profiles(inputs, outputs, nodes, rod_length, side_nodes, fric_nodes);
if (inputs->enable_fourier_baseline) {
(void)solver_compute_fourier_baseline(inputs, outputs);
}
return 0;
}

View File

@@ -0,0 +1,57 @@
#include "solver.h"
#include "solver_internal.h"
#include <math.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static void reconstruct_series(const double *in, int n, int harmonics, double *out) {
if (n <= 0) return;
double a0 = 0.0;
for (int i = 0; i < n; i++) a0 += in[i];
a0 /= (double)n;
for (int i = 0; i < n; i++) out[i] = a0;
const int hmax = (harmonics > n / 2) ? (n / 2) : harmonics;
for (int k = 1; k <= hmax; k++) {
double ak = 0.0;
double bk = 0.0;
for (int i = 0; i < n; i++) {
const double th = 2.0 * M_PI * (double)k * (double)i / (double)n;
ak += in[i] * cos(th);
bk += in[i] * sin(th);
}
ak *= 2.0 / (double)n;
bk *= 2.0 / (double)n;
for (int i = 0; i < n; i++) {
const double th = 2.0 * M_PI * (double)k * (double)i / (double)n;
out[i] += ak * cos(th) + bk * sin(th);
}
}
}
int solver_compute_fourier_baseline(const SolverInputs *inputs, SolverOutputs *outputs) {
(void)inputs;
if (!outputs || outputs->point_count <= 0) return -1;
const int n = outputs->point_count;
const int harmonics = solver_clamp(inputs->fourier_harmonics, 1, SOLVER_MAX_FOURIER_HARMONICS);
reconstruct_series(outputs->polished_load, n, harmonics, outputs->fourier_polished_load);
reconstruct_series(outputs->downhole_load, n, harmonics, outputs->fourier_downhole_load);
double rss_p = 0.0;
double rss_d = 0.0;
for (int i = 0; i < n; i++) {
const double ep = outputs->polished_load[i] - outputs->fourier_polished_load[i];
const double ed = outputs->downhole_load[i] - outputs->fourier_downhole_load[i];
rss_p += ep * ep;
rss_d += ed * ed;
}
outputs->fourier_harmonics_used = harmonics;
outputs->fourier_residual_rms_polished = sqrt(rss_p / (double)n);
outputs->fourier_residual_rms_downhole = sqrt(rss_d / (double)n);
return 0;
}

114
solver-c/src/trajectory.c Normal file
View File

@@ -0,0 +1,114 @@
#include "solver.h"
#include <math.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static double interp1(const double *x, const double *y, int n, double xq) {
if (n <= 0) {
return 0.0;
}
if (xq <= x[0]) {
return y[0];
}
if (xq >= x[n - 1]) {
return y[n - 1];
}
for (int i = 0; i < n - 1; i++) {
if (xq >= x[i] && xq <= x[i + 1]) {
const double t = (xq - x[i]) / (x[i + 1] - x[i] + 1e-30);
return y[i] + t * (y[i + 1] - y[i]);
}
}
return y[n - 1];
}
/* Curvature proxy between survey stations (SPE-173970 style dogleg), then map to measured depth along rod */
static void survey_curvature_kappa(const SolverInputs *in, double *kappa_station, int *n_k) {
const int n = in->survey_station_count;
*n_k = 0;
if (n < 3) {
return;
}
for (int i = 1; i < n; i++) {
const double ds = fmax(in->survey_md_m[i] - in->survey_md_m[i - 1], 1e-6);
const double dInc = in->survey_inc_rad[i] - in->survey_inc_rad[i - 1];
const double dAzi = in->survey_azi_rad[i] - in->survey_azi_rad[i - 1];
const double incMid = 0.5 * (in->survey_inc_rad[i] + in->survey_inc_rad[i - 1]);
const double kappa = sqrt(dInc * dInc + pow(sin(incMid) * dAzi, 2)) / ds;
kappa_station[i - 1] = kappa;
}
*n_k = n - 1;
}
static void survey_tangent_vectors(const SolverInputs *in, double tx[], double ty[], double tz[]) {
for (int i = 0; i < in->survey_station_count; i++) {
const double inc = in->survey_inc_rad[i];
const double azi = in->survey_azi_rad[i];
tx[i] = sin(inc) * cos(azi);
ty[i] = sin(inc) * sin(azi);
tz[i] = cos(inc);
}
}
void solver_trajectory_preprocess(SolverInputs *inputs, int nx, double rod_length_m) {
if (!inputs || nx <= 0 || rod_length_m <= 0.0) {
return;
}
const int nodes = nx + 1;
if (nodes > SOLVER_MAX_NODES) {
return;
}
memset(inputs->node_curvature, 0, sizeof(inputs->node_curvature));
memset(inputs->node_inc_rad, 0, sizeof(inputs->node_inc_rad));
memset(inputs->node_azi_rad, 0, sizeof(inputs->node_azi_rad));
memset(inputs->node_side_load_n, 0, sizeof(inputs->node_side_load_n));
if (inputs->survey_station_count < 3) {
inputs->geometry_valid = 0;
return;
}
double kappa_st[SOLVER_MAX_SURVEY];
double tx[SOLVER_MAX_SURVEY];
double ty[SOLVER_MAX_SURVEY];
double tz[SOLVER_MAX_SURVEY];
int nk = 0;
survey_curvature_kappa(inputs, kappa_st, &nk);
survey_tangent_vectors(inputs, tx, ty, tz);
if (nk <= 0) {
inputs->geometry_valid = 0;
return;
}
/* kappa_station[i] lives between md[i] and md[i+1]; map midpoint md */
double md_mid[SOLVER_MAX_SURVEY];
for (int i = 0; i < nk; i++) {
md_mid[i] = 0.5 * (inputs->survey_md_m[i] + inputs->survey_md_m[i + 1]);
}
const double md_top = inputs->survey_md_m[0];
const double md_bot = inputs->survey_md_m[inputs->survey_station_count - 1];
for (int j = 0; j < nodes; j++) {
const double s = rod_length_m * (double)j / (double)nx;
const double md_target = md_top + (md_bot - md_top) * (s / fmax(rod_length_m, 1e-6));
const double kappa = interp1(md_mid, kappa_st, nk, md_target);
const double tix = interp1(inputs->survey_md_m, tx, inputs->survey_station_count, md_target);
const double tiy = interp1(inputs->survey_md_m, ty, inputs->survey_station_count, md_target);
const double tiz = interp1(inputs->survey_md_m, tz, inputs->survey_station_count, md_target);
const double tnorm = fmax(sqrt(tix * tix + tiy * tiy + tiz * tiz), 1e-12);
const double nxv = tix / tnorm;
const double nyv = tiy / tnorm;
const double nzv = tiz / tnorm;
const double inc = acos(fmax(-1.0, fmin(1.0, nzv)));
const double azi = atan2(nyv, nxv);
inputs->node_curvature[j] = kappa;
inputs->node_inc_rad[j] = inc;
inputs->node_azi_rad[j] = azi;
}
inputs->geometry_valid = 1;
}

View File

@@ -0,0 +1,252 @@
#include "solver.h"
#include "solver_internal.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
static void fill_base_inputs(SolverInputs *in) {
memset(in, 0, sizeof(SolverInputs));
in->schema_version = 2;
in->workflow = 0;
in->pumping_speed = 5.0;
/* SI equivalents of typical imperial base-case magnitudes */
in->pump_depth = 1727.0 * 0.3048;
in->tubing_anchor_location = 1361.3 * 0.3048;
in->rod_friction_coefficient = 0.2;
in->stuffing_box_friction = 100.0 * 4.4482216152605;
in->pump_friction = 200.0 * 4.4482216152605;
in->taper_factor = 1.0;
in->trajectory_friction_multiplier = 1.0;
in->fluid_density_kg_m3 = 1000.0;
in->gravity = 9.80665;
in->upstroke_damping = 0.05;
in->downstroke_damping = 0.15;
in->non_dim_damping = 1.5;
in->molded_guide_mu_scale = 1.5;
in->wheeled_guide_mu_scale = 0.1;
in->other_guide_mu_scale = 2.0;
}
static int test_deterministic_shape(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs out1;
SolverOutputs out2;
if (solver_run(&inputs, &out1) != 0 || solver_run(&inputs, &out2) != 0) {
return 1;
}
if (out1.point_count != 200 || out2.point_count != 200) {
return 2;
}
for (int i = 0; i < out1.point_count; i++) {
if (fabs(out1.polished_load[i] - out2.polished_load[i]) > 1e-9) {
return 3;
}
}
return 0;
}
static int test_bounds(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs outputs;
if (solver_run(&inputs, &outputs) != 0) {
return 1;
}
if (fabs(outputs.max_polished_load - outputs.min_polished_load) < 1e-3) {
return 2;
}
if (!isfinite(outputs.max_downhole_load) || !isfinite(outputs.min_downhole_load)) {
return 3;
}
return 0;
}
static int test_fea_deterministic_shape(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs out1;
SolverOutputs out2;
if (solver_run_fea(&inputs, &out1) != 0 || solver_run_fea(&inputs, &out2) != 0) {
return 1;
}
if (out1.point_count != 200 || out2.point_count != 200) {
return 2;
}
for (int i = 0; i < out1.point_count; i++) {
if (fabs(out1.polished_load[i] - out2.polished_load[i]) > 1e-9) {
return 3;
}
}
return 0;
}
/* CFL diagnostic must be finite and physically plausible */
static int test_cfl_clamp(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs outputs;
if (solver_run_fdm(&inputs, &outputs) != 0) {
return 1;
}
if (!(outputs.max_cfl > 0.0 && outputs.max_cfl < 1e6 && outputs.wave_speed_ref_m_s > 0.0)) {
return 2;
}
return 0;
}
/* Static equilibrium helper: mean polished load should be finite and within broad physical band */
static int test_static_load_band(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs outputs;
if (solver_run_fdm(&inputs, &outputs) != 0) {
return 1;
}
double sum = 0.0;
for (int i = 0; i < outputs.point_count; i++) {
sum += outputs.polished_load[i];
}
const double mean = sum / (double)outputs.point_count;
if (!isfinite(mean) || mean <= 0.0) {
return 2;
}
return 0;
}
/* Card extrema must match reported peaks (sanity / regression) */
static int test_card_extrema_match(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs outputs;
if (solver_run_fdm(&inputs, &outputs) != 0) {
return 1;
}
double maxp = outputs.polished_load[0];
double minp = outputs.polished_load[0];
for (int i = 1; i < outputs.point_count; i++) {
if (outputs.polished_load[i] > maxp) maxp = outputs.polished_load[i];
if (outputs.polished_load[i] < minp) minp = outputs.polished_load[i];
}
if (fabs(maxp - outputs.max_polished_load) > 1e-3 || fabs(minp - outputs.min_polished_load) > 1e-3) {
return 2;
}
return 0;
}
/* Undamped run remains finite (zero Rayleigh-like stroke factors) */
static int test_undamped_finite(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
inputs.upstroke_damping = 0.0;
inputs.downstroke_damping = 0.0;
inputs.non_dim_damping = 0.0;
SolverOutputs outputs;
if (solver_run_fdm(&inputs, &outputs) != 0) {
return 1;
}
for (int i = 0; i < outputs.point_count; i++) {
if (!isfinite(outputs.polished_load[i])) {
return 2;
}
}
return 0;
}
/* Zero harmonic drive: loads stay bounded (zero-input stability) */
static int test_zero_input_bounded(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
inputs.pumping_speed = 0.0;
SolverOutputs outputs;
if (solver_run_fdm(&inputs, &outputs) != 0) {
return 1;
}
for (int i = 0; i < outputs.point_count; i++) {
if (!isfinite(outputs.polished_load[i]) || fabs(outputs.polished_load[i]) > 1e12) {
return 2;
}
}
return 0;
}
/* FDM vs FEA peak polished load tolerance (regression gate) */
static int test_fdm_fea_peak_tolerance(void) {
SolverInputs inputs;
fill_base_inputs(&inputs);
SolverOutputs fdm;
SolverOutputs fea;
if (solver_run_fdm(&inputs, &fdm) != 0 || solver_run_fea(&inputs, &fea) != 0) {
return 1;
}
const double tol = 700000.0; /* explicit FDM vs Newmark FEA — tighten after unified BC */
if (fabs(fdm.max_polished_load - fea.max_polished_load) > tol) {
return 2;
}
if (fabs(fdm.min_polished_load - fea.min_polished_load) > tol) {
return 3;
}
return 0;
}
int main(void) {
int rc = test_deterministic_shape();
if (rc != 0) {
printf("test_deterministic_shape failed: %d\n", rc);
return 1;
}
rc = test_bounds();
if (rc != 0) {
printf("test_bounds failed: %d\n", rc);
return 1;
}
rc = test_fea_deterministic_shape();
if (rc != 0) {
printf("test_fea_deterministic_shape failed: %d\n", rc);
return 1;
}
rc = test_cfl_clamp();
if (rc != 0) {
printf("test_cfl_clamp failed: %d\n", rc);
return 1;
}
rc = test_static_load_band();
if (rc != 0) {
printf("test_static_load_band failed: %d\n", rc);
return 1;
}
rc = test_card_extrema_match();
if (rc != 0) {
printf("test_card_extrema_match failed: %d\n", rc);
return 1;
}
rc = test_undamped_finite();
if (rc != 0) {
printf("test_undamped_finite failed: %d\n", rc);
return 1;
}
rc = test_zero_input_bounded();
if (rc != 0) {
printf("test_zero_input_bounded failed: %d\n", rc);
return 1;
}
rc = test_fdm_fea_peak_tolerance();
if (rc != 0) {
printf("test_fdm_fea_peak_tolerance failed: %d\n", rc);
return 1;
}
printf("solver-c tests passed\n");
return 0;
}