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:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
69
AGENTS.md
Normal 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
213
Agents/COMPUTE_PLAN.md
Normal 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 | §1–3 | **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. 14–18 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 | §6–7 | **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 tube–tube 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
133
Agents/MATH_SPEC.md
Normal 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 | Young’s 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
29
Dockerfile
Normal 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
33
Makefile
Normal 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
238
README.md
Normal 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
170
data/cases/base-case.xml
Normal 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>
|
||||
1
data/golden/default.solve.sha256
Normal file
1
data/golden/default.solve.sha256
Normal file
@@ -0,0 +1 @@
|
||||
d433dd1061c9f26679507fac42299d97d6d9c0b446651eeaa6ac03529e424fa0
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal 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
|
||||
70
docs/engineering/architecture.md
Normal file
70
docs/engineering/architecture.md
Normal 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).
|
||||
62
docs/engineering/case-schema.md
Normal file
62
docs/engineering/case-schema.md
Normal 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.
|
||||
84
docs/engineering/field-traceability.md
Normal file
84
docs/engineering/field-traceability.md
Normal 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
46
docs/engineering/units.md
Normal 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** inch–ft–lbf 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.
|
||||
83
docs/engineering/validation.md
Normal file
83
docs/engineering/validation.md
Normal 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 (1–5)
|
||||
|
||||
### 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
12
gui-ts/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
12
gui-ts/index.html
Normal file
12
gui-ts/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rod Solver GUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3213
gui-ts/package-lock.json
generated
Normal file
3213
gui-ts/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
gui-ts/package.json
Normal file
28
gui-ts/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "gui-ts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"jsdom": "^24.1.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5",
|
||||
"vitest": "^2.0.4"
|
||||
}
|
||||
}
|
||||
240
gui-ts/src/App.test.tsx
Normal file
240
gui-ts/src/App.test.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "./App";
|
||||
|
||||
const DEFAULT_CASE = {
|
||||
model: {
|
||||
wellName: "191/01-27-007-09W2/00",
|
||||
company: "Veren",
|
||||
measuredDepth: [0, 100, 200],
|
||||
inclination: [0, 10, 20],
|
||||
azimuth: [0, 90, 180],
|
||||
pumpingSpeed: 5,
|
||||
pumpDepth: 1727
|
||||
},
|
||||
rawFields: {
|
||||
WellName: "191/01-27-007-09W2/00",
|
||||
Company: "Veren",
|
||||
PumpDepth: "1727",
|
||||
PumpingSpeed: "5",
|
||||
UnitsSelection: "2",
|
||||
MeasuredDepthArray: "0:100:200",
|
||||
InclinationFromVerticalArray: "0:10:20",
|
||||
AzimuthFromNorthArray: "0:90:180",
|
||||
TaperDiameterArray: "22.225:19.05",
|
||||
TaperLengthArray: "800:927",
|
||||
TaperModulusArray: "30.5:30.5",
|
||||
RodTypeArray: "3:3",
|
||||
DesignModeIndex: "0"
|
||||
},
|
||||
unsupportedFields: ["DesignModeIndex"],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
function mockFetchOk<T>(body: T, init: { ok?: boolean } = {}): Response {
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
json: async () => body
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", "/");
|
||||
}
|
||||
});
|
||||
|
||||
describe("App tabbed shell", () => {
|
||||
it("hydrates the form from GET /case/default and renders the Well tab by default", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: RequestInfo) => {
|
||||
if (String(url).endsWith("/case/default")) {
|
||||
return mockFetchOk(DEFAULT_CASE);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${String(url)}`);
|
||||
})
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
const wellInput = screen.getByLabelText(/Well Name/i) as HTMLInputElement;
|
||||
expect(wellInput.value).toBe("191/01-27-007-09W2/00");
|
||||
});
|
||||
});
|
||||
|
||||
it("switches between tabs", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => mockFetchOk(DEFAULT_CASE))
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByLabelText(/Well Name/i));
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Trajectory/i }));
|
||||
expect(
|
||||
screen.getByText(/Well Trajectory — Survey Table/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Rod String/i }));
|
||||
expect(screen.getByText(/Rod String Taper Sections/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Advanced/i }));
|
||||
expect(screen.getByText(/Raw XML fields/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits synthesized XML to POST /solve when the user runs the solver", async () => {
|
||||
const solveBody = {
|
||||
schemaVersion: 2,
|
||||
units: "SI",
|
||||
parsed: DEFAULT_CASE,
|
||||
parseWarnings: [],
|
||||
solver: {
|
||||
pointCount: 2,
|
||||
maxPolishedLoad: 10,
|
||||
minPolishedLoad: 1,
|
||||
maxDownholeLoad: 9,
|
||||
minDownholeLoad: 2,
|
||||
warnings: [],
|
||||
card: [
|
||||
{ position: -1, polishedLoad: 1, downholeLoad: 2 },
|
||||
{ position: 1, polishedLoad: 10, downholeLoad: 9 }
|
||||
]
|
||||
},
|
||||
runMetadata: {
|
||||
deterministic: true,
|
||||
pointCount: 2,
|
||||
generatedAt: "2026-04-15T00:00:00.000Z",
|
||||
solverModel: "fdm",
|
||||
workflow: "predictive"
|
||||
}
|
||||
};
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).endsWith("/case/default")) return mockFetchOk(DEFAULT_CASE);
|
||||
if (String(url).endsWith("/solve")) return mockFetchOk(solveBody);
|
||||
throw new Error(`Unexpected fetch: ${String(url)}`);
|
||||
})
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByLabelText(/Well Name/i));
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /^Solver$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Run Solver/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(calls.some((c) => c.url.endsWith("/solve") && c.init?.method === "POST")).toBe(
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
const solveCall = calls.find((c) => c.url.endsWith("/solve"));
|
||||
const body = JSON.parse(String(solveCall?.init?.body ?? "{}"));
|
||||
expect(body.solverModel).toBeDefined();
|
||||
expect(typeof body.xml).toBe("string");
|
||||
expect(body.xml).toContain("<WellName>191/01-27-007-09W2/00</WellName>");
|
||||
});
|
||||
|
||||
it("blocks solver run when engineering checks report blocking errors", async () => {
|
||||
const badCase = {
|
||||
...DEFAULT_CASE,
|
||||
rawFields: {
|
||||
...DEFAULT_CASE.rawFields,
|
||||
PumpDepth: "3000",
|
||||
TaperLengthArray: "100:100"
|
||||
}
|
||||
};
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: RequestInfo) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).endsWith("/case/default")) return mockFetchOk(badCase);
|
||||
return mockFetchOk({});
|
||||
})
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByLabelText(/Well Name/i));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /^Solver$/i }));
|
||||
const runButton = screen.getByRole("button", { name: /Fix checks to run/i });
|
||||
expect(runButton).toBeDisabled();
|
||||
expect(calls.some((url) => url.endsWith("/solve"))).toBe(false);
|
||||
});
|
||||
|
||||
it("runs diagnostic workflow with validated surface card payload", async () => {
|
||||
const solveBody = {
|
||||
schemaVersion: 2,
|
||||
units: "SI",
|
||||
parsed: DEFAULT_CASE,
|
||||
parseWarnings: [],
|
||||
solver: {
|
||||
pointCount: 2,
|
||||
maxPolishedLoad: 10,
|
||||
minPolishedLoad: 1,
|
||||
maxDownholeLoad: 9,
|
||||
minDownholeLoad: 2,
|
||||
warnings: [],
|
||||
card: [
|
||||
{ position: -1, polishedLoad: 1, downholeLoad: 2 },
|
||||
{ position: 1, polishedLoad: 10, downholeLoad: 9 }
|
||||
]
|
||||
},
|
||||
runMetadata: {
|
||||
deterministic: true,
|
||||
pointCount: 2,
|
||||
generatedAt: "2026-04-15T00:00:00.000Z",
|
||||
solverModel: "fdm",
|
||||
workflow: "diagnostic"
|
||||
}
|
||||
};
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).endsWith("/case/default")) return mockFetchOk(DEFAULT_CASE);
|
||||
if (String(url).endsWith("/solve/validate-card")) {
|
||||
return mockFetchOk({ ok: true, qa: { ok: true }, schemaVersion: 2 });
|
||||
}
|
||||
if (String(url).endsWith("/solve")) return mockFetchOk(solveBody);
|
||||
throw new Error(`Unexpected fetch: ${String(url)}`);
|
||||
})
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByLabelText(/Well Name/i));
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Kinematics/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/-1.2,12000/i), {
|
||||
target: { value: "-1,10000\n0,12000\n1,11000\n2,10500" }
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Validate Surface Card/i }));
|
||||
await waitFor(() =>
|
||||
expect(calls.some((c) => c.url.endsWith("/solve/validate-card"))).toBe(true)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /^Solver$/i }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Diagnostic/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Run Solver/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(calls.some((c) => c.url.endsWith("/solve") && c.init?.method === "POST")).toBe(
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
const solveCall = calls.find((c) => c.url.endsWith("/solve"));
|
||||
const body = JSON.parse(String(solveCall?.init?.body ?? "{}"));
|
||||
expect(body.workflow).toBe("diagnostic");
|
||||
expect(Array.isArray(body.surfaceCard.position)).toBe(true);
|
||||
expect(Array.isArray(body.surfaceCard.load)).toBe(true);
|
||||
});
|
||||
});
|
||||
1
gui-ts/src/App.tsx
Normal file
1
gui-ts/src/App.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { App } from "./ui/App";
|
||||
86
gui-ts/src/api/client.ts
Normal file
86
gui-ts/src/api/client.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ParsedCase, SolveResponse } from "../types";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as unknown as { env?: { VITE_API_BASE?: string } }).env?.VITE_API_BASE ||
|
||||
"http://localhost:4400";
|
||||
|
||||
export type SolverModel = "fdm" | "fea" | "both";
|
||||
export type Workflow = "predictive" | "diagnostic";
|
||||
|
||||
export type SurfaceCard = {
|
||||
position: number[];
|
||||
load: number[];
|
||||
time?: number[];
|
||||
};
|
||||
|
||||
async function handleJson<T>(resp: Response): Promise<T> {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const message =
|
||||
(body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: null) || `Request failed: HTTP ${resp.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function fetchDefaultCase(signal?: AbortSignal): Promise<ParsedCase> {
|
||||
const resp = await fetch(`${API_BASE}/case/default`, { signal });
|
||||
return handleJson<ParsedCase>(resp);
|
||||
}
|
||||
|
||||
export async function parseCaseXmlApi(xml: string, signal?: AbortSignal): Promise<ParsedCase> {
|
||||
const resp = await fetch(`${API_BASE}/case/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ xml }),
|
||||
signal
|
||||
});
|
||||
return handleJson<ParsedCase>(resp);
|
||||
}
|
||||
|
||||
export type SolveArgs = {
|
||||
xml: string;
|
||||
solverModel: SolverModel;
|
||||
workflow?: Workflow;
|
||||
surfaceCard?: SurfaceCard;
|
||||
options?: {
|
||||
enableProfiles?: boolean;
|
||||
enableDiagnosticsDetail?: boolean;
|
||||
enableFourierBaseline?: boolean;
|
||||
fourierHarmonics?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function solveCase(args: SolveArgs, signal?: AbortSignal): Promise<SolveResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
xml: args.xml,
|
||||
solverModel: args.solverModel
|
||||
};
|
||||
if (args.workflow) body.workflow = args.workflow;
|
||||
if (args.surfaceCard) body.surfaceCard = args.surfaceCard;
|
||||
if (args.options) body.options = args.options;
|
||||
const resp = await fetch(`${API_BASE}/solve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
return handleJson<SolveResponse>(resp);
|
||||
}
|
||||
|
||||
export async function validateSurfaceCard(
|
||||
surfaceCard: SurfaceCard,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ ok: boolean; qa: Record<string, unknown>; schemaVersion: number }> {
|
||||
const resp = await fetch(`${API_BASE}/solve/validate-card`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ surfaceCard }),
|
||||
signal
|
||||
});
|
||||
return handleJson(resp);
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
10
gui-ts/src/main.tsx
Normal file
10
gui-ts/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
48
gui-ts/src/state/__tests__/engineeringChecks.test.ts
Normal file
48
gui-ts/src/state/__tests__/engineeringChecks.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_CASE_STATE } from "../caseModel";
|
||||
import {
|
||||
DLS_BAD_SECTION_THRESHOLD,
|
||||
PUMP_ROD_MISMATCH_M,
|
||||
runEngineeringChecks
|
||||
} from "../engineeringChecks";
|
||||
|
||||
describe("engineering checks fixed thresholds", () => {
|
||||
it("blocks run when pump depth and rod length mismatch exceeds 15 m", () => {
|
||||
const state = {
|
||||
...EMPTY_CASE_STATE,
|
||||
pumpDepth: 1000,
|
||||
taper: [
|
||||
{ diameter: 19.05, length: 980, modulus: 30.5, rodType: 3 }
|
||||
],
|
||||
survey: [
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: 1000, inc: 0, azi: 0 }
|
||||
]
|
||||
};
|
||||
|
||||
const checks = runEngineeringChecks(state);
|
||||
expect(PUMP_ROD_MISMATCH_M).toBe(15);
|
||||
expect(checks.hasBlockingError).toBe(true);
|
||||
expect(checks.issues.some((i) => i.code === "PUMP_ROD_MISMATCH_15M")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags DLS warning above 15 deg/100 threshold", () => {
|
||||
const state = {
|
||||
...EMPTY_CASE_STATE,
|
||||
pumpDepth: 1000,
|
||||
taper: [
|
||||
{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }
|
||||
],
|
||||
survey: [
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: 100, inc: 20, azi: 0 },
|
||||
{ md: 200, inc: 45, azi: 180 }
|
||||
]
|
||||
};
|
||||
|
||||
const checks = runEngineeringChecks(state);
|
||||
expect(DLS_BAD_SECTION_THRESHOLD).toBe(15);
|
||||
expect(checks.issues.some((i) => i.code === "DLS_HIGH")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
111
gui-ts/src/state/__tests__/xmlExport.test.ts
Normal file
111
gui-ts/src/state/__tests__/xmlExport.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedCase } from "../../types";
|
||||
import { hydrateFromParsed } from "../xmlImport";
|
||||
import { serializeCaseXml } from "../xmlExport";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASE_CASE_XML = path.resolve(HERE, "../../../../data/cases/base-case.xml");
|
||||
|
||||
/**
|
||||
* Minimal ParsedCase builder that extracts text content of every direct child
|
||||
* of the <Case> element. Mirrors the subset of `xml2js` behaviour that the
|
||||
* GUI import path depends on (strings only, no xml2js attr bag here since
|
||||
* round-trip on first-class fields does not require attribute preservation).
|
||||
*/
|
||||
function parseWithDom(xmlText: string): ParsedCase {
|
||||
const doc = new DOMParser().parseFromString(xmlText, "application/xml");
|
||||
const parseErr = doc.getElementsByTagName("parsererror");
|
||||
if (parseErr.length) throw new Error(parseErr[0].textContent ?? "XML parse error");
|
||||
|
||||
const caseNode = doc.getElementsByTagName("Case")[0];
|
||||
if (!caseNode) throw new Error("missing Case element");
|
||||
|
||||
const rawFields: Record<string, string> = {};
|
||||
for (let i = 0; i < caseNode.children.length; i += 1) {
|
||||
const child = caseNode.children[i];
|
||||
rawFields[child.tagName] = (child.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
return {
|
||||
model: {
|
||||
wellName: rawFields.WellName ?? "",
|
||||
company: rawFields.Company ?? "",
|
||||
pumpingSpeed: Number(rawFields.PumpingSpeed ?? 0),
|
||||
pumpDepth: Number(rawFields.PumpDepth ?? 0),
|
||||
measuredDepth: [],
|
||||
inclination: [],
|
||||
azimuth: []
|
||||
},
|
||||
rawFields,
|
||||
unsupportedFields: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("xmlExport round-trip", () => {
|
||||
const xmlText = fs.readFileSync(BASE_CASE_XML, "utf-8");
|
||||
|
||||
function raw(parsed: ParsedCase, key: string): string {
|
||||
const value = parsed.rawFields[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
it("preserves every first-class field through hydrate -> serialize", () => {
|
||||
const parsed = parseWithDom(xmlText);
|
||||
const state = hydrateFromParsed(parsed);
|
||||
const exportedXml = serializeCaseXml(state);
|
||||
const reparsed = parseWithDom(exportedXml);
|
||||
|
||||
// Well metadata
|
||||
expect(raw(reparsed, "WellName")).toBe(raw(parsed, "WellName"));
|
||||
expect(raw(reparsed, "Company")).toBe(raw(parsed, "Company"));
|
||||
|
||||
// Numeric first-class fields
|
||||
expect(Number(raw(reparsed, "PumpDepth"))).toBe(Number(raw(parsed, "PumpDepth")));
|
||||
expect(Number(raw(reparsed, "PumpingSpeed"))).toBe(Number(raw(parsed, "PumpingSpeed")));
|
||||
expect(Number(raw(reparsed, "WaterCut"))).toBe(Number(raw(parsed, "WaterCut")));
|
||||
expect(Number(raw(reparsed, "UnitsSelection"))).toBe(
|
||||
Number(raw(parsed, "UnitsSelection"))
|
||||
);
|
||||
|
||||
// Arrays preserved (value-wise — original has trailing zeros we may have stripped)
|
||||
const origMd = raw(parsed, "MeasuredDepthArray").split(":").map(Number);
|
||||
const reMd = raw(reparsed, "MeasuredDepthArray").split(":").map(Number);
|
||||
expect(reMd).toEqual(origMd);
|
||||
|
||||
const origInc = raw(parsed, "InclinationFromVerticalArray").split(":").map(Number);
|
||||
const reInc = raw(reparsed, "InclinationFromVerticalArray").split(":").map(Number);
|
||||
expect(reInc).toEqual(origInc);
|
||||
|
||||
const origTaperD = raw(parsed, "TaperDiameterArray").split(":").map(Number);
|
||||
const reTaperD = raw(reparsed, "TaperDiameterArray").split(":").map(Number);
|
||||
expect(reTaperD).toEqual(origTaperD);
|
||||
});
|
||||
|
||||
it("preserves unsupported / untouched fields verbatim", () => {
|
||||
const parsed = parseWithDom(xmlText);
|
||||
const state = hydrateFromParsed(parsed);
|
||||
const exportedXml = serializeCaseXml(state);
|
||||
const reparsed = parseWithDom(exportedXml);
|
||||
|
||||
// Representative fields not first-class in the GUI.
|
||||
expect(raw(reparsed, "Analyst")).toBe(raw(parsed, "Analyst"));
|
||||
expect(raw(reparsed, "CrankHole")).toBe(raw(parsed, "CrankHole"));
|
||||
expect(raw(reparsed, "MoldedGuideType")).toBe(raw(parsed, "MoldedGuideType"));
|
||||
expect(raw(reparsed, "Version")).toBe(raw(parsed, "Version"));
|
||||
expect(raw(reparsed, "IPRInputMode")).toBe(raw(parsed, "IPRInputMode"));
|
||||
});
|
||||
|
||||
it("reflects user edits in the serialized XML", () => {
|
||||
const parsed = parseWithDom(xmlText);
|
||||
const state = hydrateFromParsed(parsed);
|
||||
const edited = { ...state, wellName: "CHANGED-WELL", pumpingSpeed: 7.5 };
|
||||
const exportedXml = serializeCaseXml(edited);
|
||||
const reparsed = parseWithDom(exportedXml);
|
||||
|
||||
expect(raw(reparsed, "WellName")).toBe("CHANGED-WELL");
|
||||
expect(Number(raw(reparsed, "PumpingSpeed"))).toBe(7.5);
|
||||
});
|
||||
});
|
||||
179
gui-ts/src/state/caseModel.ts
Normal file
179
gui-ts/src/state/caseModel.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Canonical GUI-side case model.
|
||||
*
|
||||
* `CaseState` mirrors the first-class fields produced by
|
||||
* `solver-api/src/xmlParser.js#parseCaseXml` (SI), plus an escape-hatch
|
||||
* `rawFields` map holding every original XML element keyed by its tag name.
|
||||
*
|
||||
* The GUI is the source of truth while editing; `serializeCaseXml` in
|
||||
* `./xmlExport.ts` converts it back into the XML document shape that
|
||||
* `POST /solve` expects.
|
||||
*/
|
||||
|
||||
export type RawFieldValue = string | Record<string, unknown> | undefined;
|
||||
|
||||
export type TaperRow = {
|
||||
/** Taper diameter in the XML's native unit (mm in base case). */
|
||||
diameter: number;
|
||||
/** Taper length in the XML's native unit (typically metres / feet mix). */
|
||||
length: number;
|
||||
/** Young's modulus in Mpsi (base case stores 30.5). */
|
||||
modulus: number;
|
||||
/** Rod type code (0=steel, 3=fiberglass, 2=sinker, etc.). */
|
||||
rodType: number;
|
||||
};
|
||||
|
||||
export type SurveyRow = {
|
||||
md: number;
|
||||
inc: number;
|
||||
azi: number;
|
||||
};
|
||||
|
||||
export type CaseState = {
|
||||
// --- Well / metadata ---
|
||||
wellName: string;
|
||||
company: string;
|
||||
|
||||
// --- Depths / tubing ---
|
||||
pumpDepth: number;
|
||||
tubingAnchorLocation: number;
|
||||
tubingSize: number;
|
||||
|
||||
// --- Kinematics / surface BC ---
|
||||
pumpingSpeed: number;
|
||||
pumpingSpeedOption: number;
|
||||
pumpingUnitId: string;
|
||||
|
||||
// --- Trajectory ---
|
||||
survey: SurveyRow[];
|
||||
|
||||
// --- Rod string ---
|
||||
taper: TaperRow[];
|
||||
|
||||
// --- Pump ---
|
||||
pumpDiameter: number;
|
||||
pumpFriction: number;
|
||||
pumpIntakePressure: number;
|
||||
pumpFillageOption: number;
|
||||
percentPumpFillage: number;
|
||||
percentUpstrokeTime: number;
|
||||
percentDownstrokeTime: number;
|
||||
|
||||
// --- Fluid ---
|
||||
waterCut: number;
|
||||
waterSpecGravity: number;
|
||||
fluidLevelOilGravity: number;
|
||||
tubingGradient: number;
|
||||
|
||||
// --- Friction / damping ---
|
||||
rodFrictionCoefficient: number;
|
||||
stuffingBoxFriction: number;
|
||||
moldedGuideFrictionRatio: number;
|
||||
wheeledGuideFrictionRatio: number;
|
||||
otherGuideFrictionRatio: number;
|
||||
upStrokeDamping: number;
|
||||
downStrokeDamping: number;
|
||||
nonDimensionalFluidDamping: number;
|
||||
|
||||
// --- Units ---
|
||||
unitsSelection: number;
|
||||
|
||||
// --- Escape hatch: every raw <Case> child element, preserved verbatim. ---
|
||||
rawFields: Record<string, RawFieldValue>;
|
||||
|
||||
/**
|
||||
* Insertion order of fields in the originally loaded XML. Preserved so
|
||||
* exports produce readable diffs against the input file.
|
||||
*/
|
||||
rawFieldOrder: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime settings that are not part of the XML case file but that the GUI
|
||||
* needs to send to the solver API.
|
||||
*/
|
||||
export type RunSettings = {
|
||||
solverModel: "fdm" | "fea" | "both";
|
||||
workflow: "predictive" | "diagnostic";
|
||||
};
|
||||
|
||||
export const INITIAL_RUN_SETTINGS: RunSettings = {
|
||||
solverModel: "both",
|
||||
workflow: "predictive"
|
||||
};
|
||||
|
||||
export const EMPTY_CASE_STATE: CaseState = {
|
||||
wellName: "",
|
||||
company: "",
|
||||
pumpDepth: 0,
|
||||
tubingAnchorLocation: 0,
|
||||
tubingSize: 0,
|
||||
pumpingSpeed: 0,
|
||||
pumpingSpeedOption: 0,
|
||||
pumpingUnitId: "",
|
||||
survey: [],
|
||||
taper: [],
|
||||
pumpDiameter: 0,
|
||||
pumpFriction: 0,
|
||||
pumpIntakePressure: 0,
|
||||
pumpFillageOption: 0,
|
||||
percentPumpFillage: 0,
|
||||
percentUpstrokeTime: 50,
|
||||
percentDownstrokeTime: 50,
|
||||
waterCut: 0,
|
||||
waterSpecGravity: 1,
|
||||
fluidLevelOilGravity: 35,
|
||||
tubingGradient: 0,
|
||||
rodFrictionCoefficient: 0,
|
||||
stuffingBoxFriction: 0,
|
||||
moldedGuideFrictionRatio: 1,
|
||||
wheeledGuideFrictionRatio: 1,
|
||||
otherGuideFrictionRatio: 1,
|
||||
upStrokeDamping: 0,
|
||||
downStrokeDamping: 0,
|
||||
nonDimensionalFluidDamping: 0,
|
||||
unitsSelection: 0,
|
||||
rawFields: {},
|
||||
rawFieldOrder: []
|
||||
};
|
||||
|
||||
/** Fields that `serializeCaseXml` writes explicitly (and should therefore not be duplicated from rawFields). */
|
||||
export const FIRST_CLASS_XML_KEYS = [
|
||||
"WellName",
|
||||
"Company",
|
||||
"PumpDepth",
|
||||
"TubingAnchorLocation",
|
||||
"TubingSize",
|
||||
"PumpingSpeed",
|
||||
"PumpingSpeedOption",
|
||||
"PumpingUnitID",
|
||||
"MeasuredDepthArray",
|
||||
"InclinationFromVerticalArray",
|
||||
"AzimuthFromNorthArray",
|
||||
"TaperDiameterArray",
|
||||
"TaperLengthArray",
|
||||
"TaperModulusArray",
|
||||
"RodTypeArray",
|
||||
"PumpDiameter",
|
||||
"PumpFriction",
|
||||
"PumpIntakePressure",
|
||||
"PumpFillageOption",
|
||||
"PercentPumpFillage",
|
||||
"PercentageUpstrokeTime",
|
||||
"PercentageDownstrokeTime",
|
||||
"WaterCut",
|
||||
"WaterSpecGravity",
|
||||
"FluidLevelOilGravity",
|
||||
"TubingGradient",
|
||||
"RodFrictionCoefficient",
|
||||
"StuffingBoxFriction",
|
||||
"MoldedGuideFrictionRatio",
|
||||
"WheeledGuideFrictionRatio",
|
||||
"OtherGuideFrictionRatio",
|
||||
"UpStrokeDampingFactor",
|
||||
"DownStrokeDampingFactor",
|
||||
"NonDimensionalFluidDamping",
|
||||
"UnitsSelection"
|
||||
] as const;
|
||||
|
||||
export type FirstClassXmlKey = (typeof FIRST_CLASS_XML_KEYS)[number];
|
||||
83
gui-ts/src/state/engineeringChecks.ts
Normal file
83
gui-ts/src/state/engineeringChecks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { CaseState } from "./caseModel";
|
||||
import { computeDoglegSeverityDegPer100 } from "./trajectoryMetrics";
|
||||
|
||||
export const PUMP_ROD_MISMATCH_M = 15;
|
||||
export const DLS_BAD_SECTION_THRESHOLD = 15;
|
||||
|
||||
export type EngineeringIssue = {
|
||||
severity: "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type EngineeringChecks = {
|
||||
issues: EngineeringIssue[];
|
||||
hasBlockingError: boolean;
|
||||
};
|
||||
|
||||
export function runEngineeringChecks(state: CaseState): EngineeringChecks {
|
||||
const issues: EngineeringIssue[] = [];
|
||||
|
||||
const activeTaper = state.taper.filter((t) => Number.isFinite(t.length) && t.length > 0);
|
||||
const rodTotal = activeTaper.reduce((acc, t) => acc + t.length, 0);
|
||||
const pumpDepth = state.pumpDepth;
|
||||
if (rodTotal > 0 && pumpDepth > 0) {
|
||||
const diff = Math.abs(pumpDepth - rodTotal);
|
||||
if (diff > PUMP_ROD_MISMATCH_M) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "PUMP_ROD_MISMATCH_15M",
|
||||
message: `Pump depth (${pumpDepth.toFixed(1)}) and total rod length (${rodTotal.toFixed(
|
||||
1
|
||||
)}) differ by ${diff.toFixed(1)} m (> ${PUMP_ROD_MISMATCH_M} m limit).`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (state.survey.length < 2) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "SURVEY_TOO_SHORT",
|
||||
message: "Trajectory needs at least 2 survey stations."
|
||||
});
|
||||
} else {
|
||||
let nonMonotonic = false;
|
||||
let maxDls = 0;
|
||||
for (let i = 1; i < state.survey.length; i += 1) {
|
||||
if (state.survey[i].md <= state.survey[i - 1].md) nonMonotonic = true;
|
||||
maxDls = Math.max(maxDls, computeDoglegSeverityDegPer100(state.survey[i - 1], state.survey[i]));
|
||||
}
|
||||
if (nonMonotonic) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "SURVEY_MD_NON_MONOTONIC",
|
||||
message: "Measured depth must strictly increase between survey stations."
|
||||
});
|
||||
}
|
||||
if (maxDls > DLS_BAD_SECTION_THRESHOLD) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "DLS_HIGH",
|
||||
message: `High dogleg severity detected (max ${maxDls.toFixed(
|
||||
2
|
||||
)} deg/100 > ${DLS_BAD_SECTION_THRESHOLD} deg/100 bad-section threshold).`
|
||||
});
|
||||
}
|
||||
const maxMd = state.survey[state.survey.length - 1].md;
|
||||
if (pumpDepth > 0 && maxMd > 0 && maxMd < pumpDepth - 10) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "SURVEY_BELOW_PUMP_MISSING",
|
||||
message: `Trajectory ends at MD ${maxMd.toFixed(
|
||||
1
|
||||
)}, shallower than pump depth ${pumpDepth.toFixed(1)}.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
issues,
|
||||
hasBlockingError: issues.some((issue) => issue.severity === "error")
|
||||
};
|
||||
}
|
||||
|
||||
73
gui-ts/src/state/trajectoryMetrics.ts
Normal file
73
gui-ts/src/state/trajectoryMetrics.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { SurveyRow } from "./caseModel";
|
||||
|
||||
export type TrajectoryPoint3D = { x: number; y: number; z: number; md: number };
|
||||
export type TrajectorySegment = {
|
||||
index: number;
|
||||
a: TrajectoryPoint3D;
|
||||
b: TrajectoryPoint3D;
|
||||
dMd: number;
|
||||
dls: number;
|
||||
};
|
||||
|
||||
export function computeDoglegSeverityDegPer100(rowA: SurveyRow, rowB: SurveyRow): number {
|
||||
const dMd = rowB.md - rowA.md;
|
||||
if (!Number.isFinite(dMd) || dMd <= 1e-6) return 0;
|
||||
const inc1 = (rowA.inc * Math.PI) / 180;
|
||||
const inc2 = (rowB.inc * Math.PI) / 180;
|
||||
const azi1 = (rowA.azi * Math.PI) / 180;
|
||||
const azi2 = (rowB.azi * Math.PI) / 180;
|
||||
const cosDogleg =
|
||||
Math.cos(inc1) * Math.cos(inc2) + Math.sin(inc1) * Math.sin(inc2) * Math.cos(azi2 - azi1);
|
||||
const clamped = Math.min(1, Math.max(-1, cosDogleg));
|
||||
const doglegDeg = (Math.acos(clamped) * 180) / Math.PI;
|
||||
return (doglegDeg / dMd) * 100;
|
||||
}
|
||||
|
||||
export function buildTrajectorySegments(survey: SurveyRow[]): TrajectorySegment[] {
|
||||
if (survey.length < 2) return [];
|
||||
const points: TrajectoryPoint3D[] = [{ x: 0, y: 0, z: 0, md: survey[0].md }];
|
||||
for (let i = 1; i < survey.length; i += 1) {
|
||||
const prev = survey[i - 1];
|
||||
const curr = survey[i];
|
||||
const dMd = Math.max(curr.md - prev.md, 0);
|
||||
const incRad = (curr.inc * Math.PI) / 180;
|
||||
const azRad = (curr.azi * Math.PI) / 180;
|
||||
const dx = dMd * Math.sin(incRad) * Math.sin(azRad);
|
||||
const dy = dMd * Math.sin(incRad) * Math.cos(azRad);
|
||||
const dz = dMd * Math.cos(incRad);
|
||||
const last = points[points.length - 1];
|
||||
points.push({ x: last.x + dx, y: last.y + dy, z: last.z + dz, md: curr.md });
|
||||
}
|
||||
const segments: TrajectorySegment[] = [];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
segments.push({
|
||||
index: i - 1,
|
||||
a: points[i - 1],
|
||||
b: points[i],
|
||||
dMd: Math.max(points[i].md - points[i - 1].md, 0),
|
||||
dls: computeDoglegSeverityDegPer100(survey[i - 1], survey[i])
|
||||
});
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function interpolateAlongMd(
|
||||
segments: TrajectorySegment[],
|
||||
mdTarget: number
|
||||
): TrajectoryPoint3D | null {
|
||||
if (!segments.length) return null;
|
||||
for (const segment of segments) {
|
||||
if (mdTarget >= segment.a.md && mdTarget <= segment.b.md) {
|
||||
const span = Math.max(segment.b.md - segment.a.md, 1e-9);
|
||||
const t = (mdTarget - segment.a.md) / span;
|
||||
return {
|
||||
x: segment.a.x + (segment.b.x - segment.a.x) * t,
|
||||
y: segment.a.y + (segment.b.y - segment.a.y) * t,
|
||||
z: segment.a.z + (segment.b.z - segment.a.z) * t,
|
||||
md: mdTarget
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...segments[segments.length - 1].b };
|
||||
}
|
||||
|
||||
130
gui-ts/src/state/useCaseStore.ts
Normal file
130
gui-ts/src/state/useCaseStore.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { CaseState, SurveyRow, TaperRow } from "./caseModel";
|
||||
import { EMPTY_CASE_STATE } from "./caseModel";
|
||||
|
||||
export type CaseStore = {
|
||||
state: CaseState;
|
||||
setState: (next: CaseState) => void;
|
||||
update: <K extends keyof CaseState>(key: K, value: CaseState[K]) => void;
|
||||
setSurvey: (rows: SurveyRow[]) => void;
|
||||
addSurveyRow: (row?: Partial<SurveyRow>) => void;
|
||||
removeSurveyRow: (index: number) => void;
|
||||
updateSurveyRow: (index: number, patch: Partial<SurveyRow>) => void;
|
||||
setTaper: (rows: TaperRow[]) => void;
|
||||
addTaperRow: (row?: Partial<TaperRow>) => void;
|
||||
removeTaperRow: (index: number) => void;
|
||||
updateTaperRow: (index: number, patch: Partial<TaperRow>) => void;
|
||||
setRawField: (key: string, value: string) => void;
|
||||
};
|
||||
|
||||
export function useCaseStore(initial: CaseState = EMPTY_CASE_STATE): CaseStore {
|
||||
const [state, setStateInternal] = useState<CaseState>(initial);
|
||||
|
||||
const setState = useCallback((next: CaseState) => setStateInternal(next), []);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof CaseState>(key: K, value: CaseState[K]) => {
|
||||
setStateInternal((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setSurvey = useCallback((rows: SurveyRow[]) => {
|
||||
setStateInternal((prev) => ({ ...prev, survey: rows }));
|
||||
}, []);
|
||||
|
||||
const addSurveyRow = useCallback((row: Partial<SurveyRow> = {}) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
survey: [...prev.survey, { md: row.md ?? 0, inc: row.inc ?? 0, azi: row.azi ?? 0 }]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeSurveyRow = useCallback((index: number) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
survey: prev.survey.filter((_, i) => i !== index)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateSurveyRow = useCallback((index: number, patch: Partial<SurveyRow>) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
survey: prev.survey.map((row, i) => (i === index ? { ...row, ...patch } : row))
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTaper = useCallback((rows: TaperRow[]) => {
|
||||
setStateInternal((prev) => ({ ...prev, taper: rows }));
|
||||
}, []);
|
||||
|
||||
const addTaperRow = useCallback((row: Partial<TaperRow> = {}) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
taper: [
|
||||
...prev.taper,
|
||||
{
|
||||
diameter: row.diameter ?? 0,
|
||||
length: row.length ?? 0,
|
||||
modulus: row.modulus ?? 30.5,
|
||||
rodType: row.rodType ?? 0
|
||||
}
|
||||
]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeTaperRow = useCallback((index: number) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
taper: prev.taper.filter((_, i) => i !== index)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateTaperRow = useCallback((index: number, patch: Partial<TaperRow>) => {
|
||||
setStateInternal((prev) => ({
|
||||
...prev,
|
||||
taper: prev.taper.map((row, i) => (i === index ? { ...row, ...patch } : row))
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRawField = useCallback((key: string, value: string) => {
|
||||
setStateInternal((prev) => {
|
||||
const nextRaw = { ...prev.rawFields, [key]: value };
|
||||
const order = prev.rawFieldOrder.includes(key)
|
||||
? prev.rawFieldOrder
|
||||
: [...prev.rawFieldOrder, key];
|
||||
return { ...prev, rawFields: nextRaw, rawFieldOrder: order };
|
||||
});
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
setState,
|
||||
update,
|
||||
setSurvey,
|
||||
addSurveyRow,
|
||||
removeSurveyRow,
|
||||
updateSurveyRow,
|
||||
setTaper,
|
||||
addTaperRow,
|
||||
removeTaperRow,
|
||||
updateTaperRow,
|
||||
setRawField
|
||||
}),
|
||||
[
|
||||
state,
|
||||
setState,
|
||||
update,
|
||||
setSurvey,
|
||||
addSurveyRow,
|
||||
removeSurveyRow,
|
||||
updateSurveyRow,
|
||||
setTaper,
|
||||
addTaperRow,
|
||||
removeTaperRow,
|
||||
updateTaperRow,
|
||||
setRawField
|
||||
]
|
||||
);
|
||||
}
|
||||
160
gui-ts/src/state/xmlExport.ts
Normal file
160
gui-ts/src/state/xmlExport.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { CaseState, RawFieldValue } from "./caseModel";
|
||||
import { FIRST_CLASS_XML_KEYS } from "./caseModel";
|
||||
|
||||
/**
|
||||
* Serialize a CaseState back into the XML document shape expected by
|
||||
* `POST /solve`. Preserves original field ordering when available and keeps
|
||||
* untouched `rawFields` verbatim.
|
||||
*/
|
||||
export function serializeCaseXml(state: CaseState): string {
|
||||
const firstClassSet = new Set<string>(FIRST_CLASS_XML_KEYS);
|
||||
const firstClassValues = buildFirstClassMap(state);
|
||||
|
||||
// Preserve original order, then append any newly-added fields.
|
||||
const order: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of state.rawFieldOrder) {
|
||||
if (!seen.has(key)) {
|
||||
order.push(key);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
for (const key of FIRST_CLASS_XML_KEYS) {
|
||||
if (!seen.has(key)) {
|
||||
order.push(key);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(state.rawFields)) {
|
||||
if (!seen.has(key)) {
|
||||
order.push(key);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('<?xml version="1.0"?>');
|
||||
lines.push("<INPRoot>");
|
||||
lines.push(" <Case>");
|
||||
for (const key of order) {
|
||||
const firstClass = firstClassValues.get(key);
|
||||
if (firstClass !== undefined) {
|
||||
lines.push(` ${renderElement(key, firstClass, null)}`);
|
||||
} else if (firstClassSet.has(key)) {
|
||||
// First-class key but no explicit value mapped — skip.
|
||||
continue;
|
||||
} else {
|
||||
const raw = state.rawFields[key];
|
||||
lines.push(` ${renderElement(key, textOf(raw), attrsOf(raw))}`);
|
||||
}
|
||||
}
|
||||
lines.push(" </Case>");
|
||||
lines.push("</INPRoot>");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function buildFirstClassMap(state: CaseState): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
m.set("WellName", state.wellName);
|
||||
m.set("Company", state.company);
|
||||
m.set("PumpDepth", formatNumber(state.pumpDepth));
|
||||
m.set("TubingAnchorLocation", formatNumber(state.tubingAnchorLocation));
|
||||
m.set("TubingSize", formatNumber(state.tubingSize));
|
||||
m.set("PumpingSpeed", formatNumber(state.pumpingSpeed));
|
||||
m.set("PumpingSpeedOption", formatNumber(state.pumpingSpeedOption));
|
||||
m.set("PumpingUnitID", state.pumpingUnitId);
|
||||
|
||||
m.set("MeasuredDepthArray", serializeColonArray(state.survey.map((r) => r.md)));
|
||||
m.set("InclinationFromVerticalArray", serializeColonArray(state.survey.map((r) => r.inc)));
|
||||
m.set("AzimuthFromNorthArray", serializeColonArray(state.survey.map((r) => r.azi)));
|
||||
|
||||
m.set("TaperDiameterArray", serializeColonArray(state.taper.map((r) => r.diameter)));
|
||||
m.set("TaperLengthArray", serializeColonArray(state.taper.map((r) => r.length)));
|
||||
m.set("TaperModulusArray", serializeColonArray(state.taper.map((r) => r.modulus)));
|
||||
m.set("RodTypeArray", serializeColonArray(state.taper.map((r) => r.rodType)));
|
||||
|
||||
m.set("PumpDiameter", formatNumber(state.pumpDiameter));
|
||||
m.set("PumpFriction", formatNumber(state.pumpFriction));
|
||||
m.set("PumpIntakePressure", formatNumber(state.pumpIntakePressure));
|
||||
m.set("PumpFillageOption", formatNumber(state.pumpFillageOption));
|
||||
m.set("PercentPumpFillage", formatNumber(state.percentPumpFillage));
|
||||
m.set("PercentageUpstrokeTime", formatNumber(state.percentUpstrokeTime));
|
||||
m.set("PercentageDownstrokeTime", formatNumber(state.percentDownstrokeTime));
|
||||
|
||||
m.set("WaterCut", formatNumber(state.waterCut));
|
||||
m.set("WaterSpecGravity", formatNumber(state.waterSpecGravity));
|
||||
m.set("FluidLevelOilGravity", formatNumber(state.fluidLevelOilGravity));
|
||||
m.set("TubingGradient", formatNumber(state.tubingGradient));
|
||||
|
||||
m.set("RodFrictionCoefficient", formatNumber(state.rodFrictionCoefficient));
|
||||
m.set("StuffingBoxFriction", formatNumber(state.stuffingBoxFriction));
|
||||
m.set("MoldedGuideFrictionRatio", formatNumber(state.moldedGuideFrictionRatio));
|
||||
m.set("WheeledGuideFrictionRatio", formatNumber(state.wheeledGuideFrictionRatio));
|
||||
m.set("OtherGuideFrictionRatio", formatNumber(state.otherGuideFrictionRatio));
|
||||
m.set("UpStrokeDampingFactor", formatNumber(state.upStrokeDamping));
|
||||
m.set("DownStrokeDampingFactor", formatNumber(state.downStrokeDamping));
|
||||
m.set("NonDimensionalFluidDamping", formatNumber(state.nonDimensionalFluidDamping));
|
||||
|
||||
m.set("UnitsSelection", formatNumber(state.unitsSelection));
|
||||
return m;
|
||||
}
|
||||
|
||||
function textOf(value: RawFieldValue): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj._ === "string") return obj._;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function attrsOf(value: RawFieldValue): Record<string, string> | null {
|
||||
if (value && typeof value === "object") {
|
||||
const attrs = (value as Record<string, unknown>).$;
|
||||
if (attrs && typeof attrs === "object") {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
|
||||
out[k] = String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderElement(
|
||||
tag: string,
|
||||
text: string,
|
||||
attrs: Record<string, string> | null
|
||||
): string {
|
||||
const attrStr = attrs
|
||||
? Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${escapeXml(v)}"`)
|
||||
.join("")
|
||||
: "";
|
||||
if (!text) {
|
||||
return `<${tag}${attrStr} />`;
|
||||
}
|
||||
return `<${tag}${attrStr}>${escapeXml(text)}</${tag}>`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
// Trim trailing zeros while keeping up to 6 decimals to match base-case style.
|
||||
const fixed = value.toFixed(6);
|
||||
return fixed.replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function serializeColonArray(values: number[]): string {
|
||||
if (values.length === 0) return "0";
|
||||
return values.map((v) => formatNumber(v)).join(":");
|
||||
}
|
||||
145
gui-ts/src/state/xmlImport.ts
Normal file
145
gui-ts/src/state/xmlImport.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { ParsedCase } from "../types";
|
||||
import {
|
||||
EMPTY_CASE_STATE,
|
||||
type CaseState,
|
||||
type RawFieldValue,
|
||||
type SurveyRow,
|
||||
type TaperRow
|
||||
} from "./caseModel";
|
||||
|
||||
/** Flatten xml2js node to its text content (preserves attr bag in '$' if present). */
|
||||
function textOf(value: RawFieldValue): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj._ === "string") return obj._;
|
||||
if (Object.keys(obj).length === 1 && "$" in obj) return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function numberOf(value: RawFieldValue, fallback = 0): number {
|
||||
const text = textOf(value).trim();
|
||||
if (!text) return fallback;
|
||||
const n = Number(text);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function stringOf(value: RawFieldValue, fallback = ""): string {
|
||||
const text = textOf(value).trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function parseColonArray(value: RawFieldValue): number[] {
|
||||
const text = textOf(value);
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(":")
|
||||
.map((piece) => piece.trim())
|
||||
.filter((piece) => piece.length > 0)
|
||||
.map((piece) => Number(piece))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate a CaseState from the `parsed` block returned by the solver-api.
|
||||
* Values come primarily from `rawFields` (pre-normalization) so the GUI
|
||||
* edits the XML-native units directly. `parsed.model` is used only as a
|
||||
* fallback when `rawFields` lacks an entry.
|
||||
*/
|
||||
export function hydrateFromParsed(parsed: ParsedCase): CaseState {
|
||||
const raw = (parsed.rawFields ?? {}) as Record<string, RawFieldValue>;
|
||||
const model = parsed.model ?? ({} as ParsedCase["model"]);
|
||||
const rawFieldOrder = Object.keys(raw);
|
||||
|
||||
const md = parseColonArray(raw.MeasuredDepthArray);
|
||||
const inc = parseColonArray(raw.InclinationFromVerticalArray);
|
||||
const azi = parseColonArray(raw.AzimuthFromNorthArray);
|
||||
const surveyLen = Math.max(md.length, inc.length, azi.length);
|
||||
const survey: SurveyRow[] = [];
|
||||
for (let i = 0; i < surveyLen; i += 1) {
|
||||
survey.push({
|
||||
md: md[i] ?? 0,
|
||||
inc: inc[i] ?? 0,
|
||||
azi: azi[i] ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
const diam = parseColonArray(raw.TaperDiameterArray);
|
||||
const length = parseColonArray(raw.TaperLengthArray);
|
||||
const modulus = parseColonArray(raw.TaperModulusArray);
|
||||
const rodType = parseColonArray(raw.RodTypeArray);
|
||||
const taperLen = Math.max(diam.length, length.length, modulus.length, rodType.length);
|
||||
const taper: TaperRow[] = [];
|
||||
for (let i = 0; i < taperLen; i += 1) {
|
||||
// Stop appending "zero" rows once we've passed the meaningful entries;
|
||||
// TaperCount is the authoritative limit but we keep all rows to preserve
|
||||
// round-trip exactly.
|
||||
taper.push({
|
||||
diameter: diam[i] ?? 0,
|
||||
length: length[i] ?? 0,
|
||||
modulus: modulus[i] ?? 0,
|
||||
rodType: rodType[i] ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...EMPTY_CASE_STATE,
|
||||
wellName: stringOf(raw.WellName, model.wellName ?? ""),
|
||||
company: stringOf(raw.Company, model.company ?? ""),
|
||||
|
||||
pumpDepth: numberOf(raw.PumpDepth, model.pumpDepth ?? 0),
|
||||
tubingAnchorLocation: numberOf(raw.TubingAnchorLocation, model.tubingAnchorLocation ?? 0),
|
||||
tubingSize: numberOf(raw.TubingSize, model.tubingSize ?? 0),
|
||||
|
||||
pumpingSpeed: numberOf(raw.PumpingSpeed, model.pumpingSpeed ?? 0),
|
||||
pumpingSpeedOption: numberOf(raw.PumpingSpeedOption, model.pumpingSpeedOption ?? 0),
|
||||
pumpingUnitId: stringOf(raw.PumpingUnitID, model.pumpingUnitId ?? ""),
|
||||
|
||||
survey,
|
||||
taper,
|
||||
|
||||
pumpDiameter: numberOf(raw.PumpDiameter, model.pumpDiameter ?? 0),
|
||||
pumpFriction: numberOf(raw.PumpFriction, model.pumpFriction ?? 0),
|
||||
pumpIntakePressure: numberOf(raw.PumpIntakePressure, model.pumpIntakePressure ?? 0),
|
||||
pumpFillageOption: numberOf(raw.PumpFillageOption, model.pumpFillageOption ?? 0),
|
||||
percentPumpFillage: numberOf(raw.PercentPumpFillage, model.percentPumpFillage ?? 0),
|
||||
percentUpstrokeTime: numberOf(raw.PercentageUpstrokeTime, model.percentUpstrokeTime ?? 50),
|
||||
percentDownstrokeTime: numberOf(raw.PercentageDownstrokeTime, model.percentDownstrokeTime ?? 50),
|
||||
|
||||
waterCut: numberOf(raw.WaterCut, model.waterCut ?? 0),
|
||||
waterSpecGravity: numberOf(raw.WaterSpecGravity, model.waterSpecGravity ?? 1),
|
||||
fluidLevelOilGravity: numberOf(raw.FluidLevelOilGravity, model.fluidLevelOilGravity ?? 35),
|
||||
tubingGradient: numberOf(raw.TubingGradient, model.tubingGradient ?? 0),
|
||||
|
||||
rodFrictionCoefficient: numberOf(
|
||||
raw.RodFrictionCoefficient,
|
||||
model.rodFrictionCoefficient ?? 0
|
||||
),
|
||||
stuffingBoxFriction: numberOf(raw.StuffingBoxFriction, model.stuffingBoxFriction ?? 0),
|
||||
moldedGuideFrictionRatio: numberOf(
|
||||
raw.MoldedGuideFrictionRatio,
|
||||
model.moldedGuideFrictionRatio ?? 1
|
||||
),
|
||||
wheeledGuideFrictionRatio: numberOf(
|
||||
raw.WheeledGuideFrictionRatio,
|
||||
model.wheeledGuideFrictionRatio ?? 1
|
||||
),
|
||||
otherGuideFrictionRatio: numberOf(
|
||||
raw.OtherGuideFrictionRatio,
|
||||
model.otherGuideFrictionRatio ?? 1
|
||||
),
|
||||
upStrokeDamping: numberOf(raw.UpStrokeDampingFactor, model.upStrokeDamping ?? 0),
|
||||
downStrokeDamping: numberOf(raw.DownStrokeDampingFactor, model.downStrokeDamping ?? 0),
|
||||
nonDimensionalFluidDamping: numberOf(
|
||||
raw.NonDimensionalFluidDamping,
|
||||
model.nonDimensionalFluidDamping ?? 0
|
||||
),
|
||||
|
||||
unitsSelection: numberOf(raw.UnitsSelection, model.unitsSelection ?? 0),
|
||||
|
||||
rawFields: { ...raw },
|
||||
rawFieldOrder
|
||||
};
|
||||
}
|
||||
383
gui-ts/src/styles.css
Normal file
383
gui-ts/src/styles.css
Normal file
@@ -0,0 +1,383 @@
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--panel: #111827;
|
||||
--panel-2: #0f172a;
|
||||
--panel-3: #1e293b;
|
||||
--border: #334155;
|
||||
--border-strong: #475569;
|
||||
--text: #e5e7eb;
|
||||
--text-dim: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #38bdf8;
|
||||
--accent-2: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", system-ui, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(90deg, #0f172a, #111827);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.app-title { font-weight: 600; letter-spacing: 0.02em; }
|
||||
.app-logo { margin-right: 8px; color: var(--accent); }
|
||||
|
||||
.app-header-meta { display: flex; gap: 6px; }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--text-dim);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
/* Tab strip */
|
||||
.tab-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--panel-2);
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.tab:hover { color: var(--text); background: var(--panel-3); }
|
||||
.tab-active {
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Tab body */
|
||||
.tab-body {
|
||||
flex: 1;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 14px;
|
||||
min-height: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Fieldset */
|
||||
.panel-fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-2);
|
||||
margin: 0;
|
||||
}
|
||||
.panel-fieldset legend {
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin: 4px 0 8px 0;
|
||||
}
|
||||
.panel-note code { background: var(--panel-3); padding: 1px 4px; border-radius: 3px; }
|
||||
|
||||
/* Label+input row */
|
||||
.panel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.panel-row label { font-size: 12px; color: var(--text-dim); }
|
||||
.panel-row-input { display: flex; flex-direction: column; gap: 3px; }
|
||||
.panel-row-hint { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.tab-grid { display: grid; gap: 12px; }
|
||||
.tab-grid.two { grid-template-columns: 1fr 1fr; }
|
||||
.tab-grid.three { grid-template-columns: 1fr 1fr 1fr; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.tab-grid.two, .tab-grid.three { grid-template-columns: 1fr; }
|
||||
.panel-row { grid-template-columns: 140px 1fr; }
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.panel-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--panel-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.panel-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(56,189,248,0.15);
|
||||
}
|
||||
.panel-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.panel-checkbox { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
.panel-radio-group { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.panel-radio { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--panel-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.btn:hover { background: #243448; border-color: var(--accent); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: #0ea5e9; border-color: #0284c7; color: #001018; font-weight: 600; }
|
||||
.btn-primary:hover { background: #38bdf8; border-color: #0ea5e9; }
|
||||
.btn-danger { color: #fecaca; border-color: #7f1d1d; background: transparent; padding: 2px 8px; font-size: 11px; }
|
||||
.btn-danger:hover { background: #7f1d1d; color: #fff; border-color: #991b1b; }
|
||||
.btn-secondary { background: var(--panel-3); }
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 4px 0 8px 0;
|
||||
}
|
||||
.action-row { display: flex; justify-content: flex-end; gap: 8px; padding-top: 10px; }
|
||||
|
||||
/* Tables */
|
||||
.table-scroll {
|
||||
max-height: 380px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.data-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--panel);
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
z-index: 1;
|
||||
}
|
||||
.data-table tbody td {
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table tbody tr.row-selected td {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
.data-table tbody tr[role="button"]:focus-visible td {
|
||||
outline: 1px solid #38bdf8;
|
||||
outline-offset: -1px;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
.data-table tbody td .panel-input { padding: 3px 6px; font-size: 11px; }
|
||||
.data-table .empty-row { text-align: center; color: var(--text-muted); padding: 18px; }
|
||||
|
||||
/* KPI grid */
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.kpi-cell {
|
||||
background: var(--panel-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.kpi-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.kpi-val { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
|
||||
@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* Callouts */
|
||||
.callout {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--panel-3);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
.callout-info { border-left-color: var(--accent); }
|
||||
.callout-error { border-left-color: var(--danger); color: #fca5a5; }
|
||||
.callout-warning { border-left-color: var(--accent-2); color: #fcd34d; }
|
||||
|
||||
.warning-list { margin: 0; padding-left: 16px; color: #fcd34d; font-size: 12px; }
|
||||
.warning-list li { margin-bottom: 3px; }
|
||||
|
||||
.mono-block {
|
||||
font-family: "JetBrains Mono", "Fira Code", "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
background: var(--panel-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.advanced-textarea {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
background: var(--panel-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.app-statusbar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -2px;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* uPlot palette overrides for dark theme */
|
||||
.uplot-host .u-legend { color: var(--text-dim); font-size: 11px; }
|
||||
.uplot-host .u-wrap { background: var(--panel-3); }
|
||||
|
||||
/* 3D wellbore */
|
||||
.wellbore-3d-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-3);
|
||||
padding: 8px;
|
||||
}
|
||||
.wellbore-3d {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: #020617;
|
||||
}
|
||||
.wellbore-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.wellbore-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.wellbore-legend i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.wellbore-kpis {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
48
gui-ts/src/testSetup.ts
Normal file
48
gui-ts/src/testSetup.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
/**
|
||||
* uPlot touches `window.matchMedia` at module-load time for HiDPI handling;
|
||||
* jsdom doesn't provide it, so stub a minimal matcher before the import
|
||||
* graph resolves.
|
||||
*/
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
} as unknown as MediaQueryList);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && typeof window.ResizeObserver === "undefined") {
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(window as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
|
||||
ResizeObserverStub;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && typeof HTMLCanvasElement !== "undefined") {
|
||||
const proto = HTMLCanvasElement.prototype as HTMLCanvasElement["prototype"] & {
|
||||
getContext?: (contextId: string, options?: unknown) => unknown;
|
||||
};
|
||||
if (typeof proto.getContext !== "function") {
|
||||
proto.getContext = () => null;
|
||||
} else {
|
||||
const original = proto.getContext;
|
||||
proto.getContext = function (contextId: string, options?: unknown) {
|
||||
try {
|
||||
return original.call(this, contextId, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
145
gui-ts/src/types.ts
Normal file
145
gui-ts/src/types.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Types mirroring the solver-api JSON responses. These are advisory — the
|
||||
* server remains the source of truth (`schemaVersion: 2`).
|
||||
*/
|
||||
|
||||
export type ParsedModel = {
|
||||
wellName: string;
|
||||
company: string;
|
||||
measuredDepth: number[];
|
||||
inclination: number[];
|
||||
azimuth: number[];
|
||||
pumpingSpeed: number;
|
||||
pumpDepth: number;
|
||||
tubingAnchorLocation?: number;
|
||||
rodFrictionCoefficient?: number;
|
||||
stuffingBoxFriction?: number;
|
||||
pumpFriction?: number;
|
||||
waterCut?: number;
|
||||
waterSpecGravity?: number;
|
||||
fluidLevelOilGravity?: number;
|
||||
taperDiameter?: number[];
|
||||
taperLength?: number[];
|
||||
taperModulus?: number[];
|
||||
rodType?: number[];
|
||||
tubingSize?: number;
|
||||
unitsSelection?: number;
|
||||
upStrokeDamping?: number;
|
||||
downStrokeDamping?: number;
|
||||
nonDimensionalFluidDamping?: number;
|
||||
moldedGuideFrictionRatio?: number;
|
||||
wheeledGuideFrictionRatio?: number;
|
||||
otherGuideFrictionRatio?: number;
|
||||
pumpDiameter?: number;
|
||||
pumpIntakePressure?: number;
|
||||
tubingGradient?: number;
|
||||
pumpFillageOption?: number;
|
||||
percentPumpFillage?: number;
|
||||
percentUpstrokeTime?: number;
|
||||
percentDownstrokeTime?: number;
|
||||
pumpingUnitId?: string;
|
||||
pumpingSpeedOption?: number;
|
||||
};
|
||||
|
||||
export type ParsedCase = {
|
||||
model: ParsedModel;
|
||||
unsupportedFields: string[];
|
||||
rawFields: Record<string, string | Record<string, unknown> | undefined>;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export type CardPoint = {
|
||||
position: number;
|
||||
polishedLoad: number;
|
||||
downholeLoad: number;
|
||||
polishedStressPa?: number;
|
||||
sideLoadN?: number;
|
||||
};
|
||||
|
||||
export type SolverOutput = {
|
||||
pointCount: number;
|
||||
maxPolishedLoad: number;
|
||||
minPolishedLoad: number;
|
||||
maxDownholeLoad: number;
|
||||
minDownholeLoad: number;
|
||||
gasInterference?: boolean;
|
||||
maxCfl?: number;
|
||||
waveSpeedRefMPerS?: number;
|
||||
warnings: string[];
|
||||
card: CardPoint[];
|
||||
pumpMovement?: {
|
||||
stroke: number;
|
||||
position: number[];
|
||||
velocity: number[];
|
||||
};
|
||||
profiles?: {
|
||||
nodeCount: number;
|
||||
trajectory3D: Array<{ md: number; curvature: number; inclination: number; azimuth: number }>;
|
||||
sideLoadProfile: number[];
|
||||
frictionProfile: number[];
|
||||
};
|
||||
diagnostics?: {
|
||||
valveStates: Array<{ travelingOpen: boolean; standingOpen: boolean }>;
|
||||
chamberPressurePa: number[];
|
||||
gasFraction: number[];
|
||||
};
|
||||
fourierBaseline?: null | {
|
||||
harmonics: number;
|
||||
residualRmsPolished: number;
|
||||
residualRmsDownhole: number;
|
||||
card: Array<{ position: number; polishedLoad: number; downholeLoad: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SolveResponse = {
|
||||
schemaVersion?: number;
|
||||
units?: string;
|
||||
parseWarnings?: string[];
|
||||
surfaceCardQa?: Record<string, unknown> | null;
|
||||
fingerprint?: string;
|
||||
parsed: ParsedCase;
|
||||
solver: SolverOutput;
|
||||
pumpMovement?: SolverOutput["pumpMovement"] | null;
|
||||
solvers?: {
|
||||
fdm: SolverOutput;
|
||||
fea: SolverOutput;
|
||||
};
|
||||
comparison?: {
|
||||
schemaVersion?: number;
|
||||
peakLoadDeltas?: {
|
||||
polishedMaxDelta: number;
|
||||
polishedMinDelta: number;
|
||||
downholeMaxDelta: number;
|
||||
downholeMinDelta: number;
|
||||
};
|
||||
polishedMaxDelta: number;
|
||||
polishedMinDelta: number;
|
||||
downholeMaxDelta: number;
|
||||
downholeMinDelta: number;
|
||||
residualSummary?: { points: number; rms: number };
|
||||
pointwiseResiduals?: {
|
||||
points: number;
|
||||
series: Array<{
|
||||
position: number;
|
||||
polishedLoadResidual: number;
|
||||
downholeLoadResidual: number;
|
||||
}>;
|
||||
};
|
||||
fourier?: null | {
|
||||
baselineName?: string;
|
||||
points?: number;
|
||||
residualRms?: number;
|
||||
};
|
||||
};
|
||||
verbose?: Record<string, unknown>;
|
||||
runMetadata: {
|
||||
deterministic: boolean;
|
||||
pointCount: number;
|
||||
generatedAt: string;
|
||||
source?: string;
|
||||
solverModel?: "fdm" | "fea" | "both";
|
||||
workflow?: string;
|
||||
schemaVersion?: number;
|
||||
units?: string;
|
||||
};
|
||||
};
|
||||
262
gui-ts/src/ui/App.tsx
Normal file
262
gui-ts/src/ui/App.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Tabs, type TabDef } from "./Tabs";
|
||||
import { useCaseStore } from "../state/useCaseStore";
|
||||
import {
|
||||
EMPTY_CASE_STATE,
|
||||
type RunSettings,
|
||||
INITIAL_RUN_SETTINGS
|
||||
} from "../state/caseModel";
|
||||
import { hydrateFromParsed } from "../state/xmlImport";
|
||||
import { serializeCaseXml } from "../state/xmlExport";
|
||||
import { fetchDefaultCase, solveCase, validateSurfaceCard, type SurfaceCard } from "../api/client";
|
||||
import type { SolveResponse } from "../types";
|
||||
import { WellTab } from "./tabs/WellTab";
|
||||
import { TrajectoryTab } from "./tabs/TrajectoryTab";
|
||||
import { KinematicsTab } from "./tabs/KinematicsTab";
|
||||
import { RodStringTab } from "./tabs/RodStringTab";
|
||||
import { PumpTab } from "./tabs/PumpTab";
|
||||
import { FluidTab } from "./tabs/FluidTab";
|
||||
import { SolverTab } from "./tabs/SolverTab";
|
||||
import { ResultsTab } from "./tabs/ResultsTab";
|
||||
import { AdvancedTab } from "./tabs/AdvancedTab";
|
||||
import { runEngineeringChecks } from "../state/engineeringChecks";
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "tab-well", label: "Well" },
|
||||
{ id: "tab-trajectory", label: "Trajectory" },
|
||||
{ id: "tab-kinematics", label: "Kinematics" },
|
||||
{ id: "tab-rod", label: "Rod String" },
|
||||
{ id: "tab-pump", label: "Pump" },
|
||||
{ id: "tab-fluid", label: "Fluid" },
|
||||
{ id: "tab-solver", label: "Solver" },
|
||||
{ id: "tab-results", label: "Results" },
|
||||
{ id: "tab-advanced", label: "Advanced / XML" }
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const store = useCaseStore(EMPTY_CASE_STATE);
|
||||
const [runSettings, setRunSettings] = useState<RunSettings>(INITIAL_RUN_SETTINGS);
|
||||
const [activeTab, setActiveTab] = useState<string>(TABS[0].id);
|
||||
const [result, setResult] = useState<SolveResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string>("Loading base case…");
|
||||
const [surfaceCardText, setSurfaceCardText] = useState<string>("");
|
||||
const [surfaceCardQaMessage, setSurfaceCardQaMessage] = useState<string | null>(null);
|
||||
const [surfaceCardQaError, setSurfaceCardQaError] = useState<string | null>(null);
|
||||
const [validatingSurfaceCard, setValidatingSurfaceCard] = useState(false);
|
||||
const hydrated = useRef(false);
|
||||
const engineeringChecks = useMemo(() => runEngineeringChecks(store.state), [store.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated.current) return;
|
||||
hydrated.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const parsed = await fetchDefaultCase();
|
||||
store.setState(hydrateFromParsed(parsed));
|
||||
setStatusMessage("Base case loaded — ready to edit / solve");
|
||||
} catch (e) {
|
||||
setStatusMessage("Failed to load base case");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const parsedSurfaceCard = useMemo(() => parseSurfaceCard(surfaceCardText), [surfaceCardText]);
|
||||
|
||||
const handleValidateSurfaceCard = useCallback(async () => {
|
||||
setValidatingSurfaceCard(true);
|
||||
setSurfaceCardQaMessage(null);
|
||||
setSurfaceCardQaError(null);
|
||||
try {
|
||||
if (parsedSurfaceCard.errors.length) {
|
||||
throw new Error(parsedSurfaceCard.errors.join(" "));
|
||||
}
|
||||
if (parsedSurfaceCard.position.length < 4) {
|
||||
throw new Error("Need at least 4 points for a useful diagnostic surface card.");
|
||||
}
|
||||
const qa = await validateSurfaceCard({
|
||||
position: parsedSurfaceCard.position,
|
||||
load: parsedSurfaceCard.load
|
||||
});
|
||||
if (qa.ok) {
|
||||
setSurfaceCardQaMessage(`QA OK (schema v${qa.schemaVersion}).`);
|
||||
} else {
|
||||
setSurfaceCardQaMessage("QA returned warnings. You can still run diagnostic solve.");
|
||||
}
|
||||
} catch (error) {
|
||||
setSurfaceCardQaError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setValidatingSurfaceCard(false);
|
||||
}
|
||||
}, [parsedSurfaceCard]);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (engineeringChecks.hasBlockingError) {
|
||||
setError("Please fix blocking engineering checks before running the solver.");
|
||||
setStatusMessage("Blocked by engineering checks");
|
||||
setActiveTab("tab-solver");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatusMessage("Running solver…");
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const xml = serializeCaseXml(store.state);
|
||||
let surfaceCard: SurfaceCard | undefined;
|
||||
if (runSettings.workflow === "diagnostic") {
|
||||
if (parsedSurfaceCard.errors.length) {
|
||||
throw new Error(`Diagnostic card input invalid: ${parsedSurfaceCard.errors.join(" ")}`);
|
||||
}
|
||||
if (parsedSurfaceCard.position.length < 4) {
|
||||
throw new Error("Diagnostic workflow requires a surface card with at least 4 points.");
|
||||
}
|
||||
surfaceCard = {
|
||||
position: parsedSurfaceCard.position,
|
||||
load: parsedSurfaceCard.load
|
||||
};
|
||||
}
|
||||
const resp = await solveCase({
|
||||
xml,
|
||||
solverModel: runSettings.solverModel,
|
||||
workflow: runSettings.workflow,
|
||||
surfaceCard,
|
||||
options: {
|
||||
enableProfiles: true,
|
||||
enableDiagnosticsDetail: runSettings.workflow === "diagnostic"
|
||||
}
|
||||
});
|
||||
setResult(resp);
|
||||
const dt = (performance.now() - t0) / 1000;
|
||||
setElapsed(dt);
|
||||
setLastRunAt(new Date().toLocaleTimeString());
|
||||
setStatusMessage(`Done in ${dt.toFixed(1)}s`);
|
||||
setActiveTab("tab-results");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setStatusMessage("Error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [engineeringChecks.hasBlockingError, parsedSurfaceCard, runSettings, store.state]);
|
||||
|
||||
const handleExportXml = useCallback(() => {
|
||||
const xml = serializeCaseXml(store.state);
|
||||
const blob = new Blob([xml], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
const safeName = (store.state.wellName || "case").replace(/[^a-z0-9_.-]+/gi, "_");
|
||||
anchor.download = `${safeName || "case"}.xml`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setStatusMessage("XML exported");
|
||||
}, [store.state]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<div className="app-title">
|
||||
<span className="app-logo" aria-hidden="true">
|
||||
⬛
|
||||
</span>
|
||||
Rods-Cursor — Case Editor & Solver
|
||||
</div>
|
||||
<div className="app-header-meta">
|
||||
<span className="pill">{runSettings.solverModel.toUpperCase()}</span>
|
||||
<span className="pill">{runSettings.workflow}</span>
|
||||
{lastRunAt && <span className="pill">Last: {lastRunAt}</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<main className="tab-body">
|
||||
{activeTab === "tab-well" && <WellTab store={store} />}
|
||||
{activeTab === "tab-trajectory" && <TrajectoryTab store={store} />}
|
||||
{activeTab === "tab-kinematics" && (
|
||||
<KinematicsTab
|
||||
store={store}
|
||||
surfaceCardText={surfaceCardText}
|
||||
onSurfaceCardTextChange={setSurfaceCardText}
|
||||
onValidateSurfaceCard={handleValidateSurfaceCard}
|
||||
validatingSurfaceCard={validatingSurfaceCard}
|
||||
surfaceCardQaMessage={surfaceCardQaMessage}
|
||||
surfaceCardQaError={surfaceCardQaError}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "tab-rod" && <RodStringTab store={store} />}
|
||||
{activeTab === "tab-pump" && <PumpTab store={store} />}
|
||||
{activeTab === "tab-fluid" && <FluidTab store={store} />}
|
||||
{activeTab === "tab-solver" && (
|
||||
<SolverTab
|
||||
store={store}
|
||||
runSettings={runSettings}
|
||||
onRunSettingsChange={setRunSettings}
|
||||
onRun={handleRun}
|
||||
onExportXml={handleExportXml}
|
||||
loading={loading}
|
||||
checks={engineeringChecks}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "tab-results" && (
|
||||
<ResultsTab
|
||||
result={result}
|
||||
loading={loading}
|
||||
error={error}
|
||||
lastRunAt={lastRunAt}
|
||||
elapsedSeconds={elapsed}
|
||||
caseState={store.state}
|
||||
checks={engineeringChecks}
|
||||
onNavigateTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "tab-advanced" && <AdvancedTab store={store} />}
|
||||
</main>
|
||||
|
||||
<footer className="app-statusbar">
|
||||
<span>{statusMessage}</span>
|
||||
<span>Well: {store.state.wellName || "—"}</span>
|
||||
<span>Taper sections: {store.state.taper.filter((t) => t.length > 0).length}</span>
|
||||
<span>Survey stations: {store.state.survey.length}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseSurfaceCard(text: string): {
|
||||
position: number[];
|
||||
load: number[];
|
||||
errors: string[];
|
||||
} {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const position: number[] = [];
|
||||
const load: number[] = [];
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const parts = lines[i].split(/[,\s;]+/).filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
errors.push(`Line ${i + 1} must contain position and load values.`);
|
||||
continue;
|
||||
}
|
||||
const p = Number(parts[0]);
|
||||
const l = Number(parts[1]);
|
||||
if (!Number.isFinite(p) || !Number.isFinite(l)) {
|
||||
errors.push(`Line ${i + 1} has non-numeric values.`);
|
||||
continue;
|
||||
}
|
||||
position.push(p);
|
||||
load.push(l);
|
||||
}
|
||||
return { position, load, errors };
|
||||
}
|
||||
50
gui-ts/src/ui/Tabs.tsx
Normal file
50
gui-ts/src/ui/Tabs.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type TabDef = { id: string; label: string };
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: TabDef[];
|
||||
active: string;
|
||||
onChange: (id: string) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function Tabs(props: TabsProps) {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const syncHash = () => {
|
||||
const hash = window.location.hash.replace(/^#/, "");
|
||||
if (hash && props.tabs.some((t) => t.id === hash) && hash !== props.active) {
|
||||
props.onChange(hash);
|
||||
}
|
||||
};
|
||||
window.addEventListener("hashchange", syncHash);
|
||||
syncHash();
|
||||
return () => window.removeEventListener("hashchange", syncHash);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.active]);
|
||||
|
||||
return (
|
||||
<div className="tab-strip" role="tablist" aria-label={props.ariaLabel ?? "Tabs"}>
|
||||
{props.tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={props.active === tab.id}
|
||||
className={`tab ${props.active === tab.id ? "tab-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onChange(tab.id);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = tab.id;
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
gui-ts/src/ui/common/CheckboxField.tsx
Normal file
22
gui-ts/src/ui/common/CheckboxField.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export type CheckboxFieldProps = {
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function CheckboxField(props: CheckboxFieldProps) {
|
||||
return (
|
||||
<label className="panel-checkbox">
|
||||
<input
|
||||
id={props.id}
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => props.onChange(e.target.checked)}
|
||||
/>
|
||||
{props.label ? <span>{props.label}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
13
gui-ts/src/ui/common/Fieldset.tsx
Normal file
13
gui-ts/src/ui/common/Fieldset.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export function Fieldset({
|
||||
legend,
|
||||
children
|
||||
}: PropsWithChildren<{ legend: string }>) {
|
||||
return (
|
||||
<fieldset className="panel-fieldset">
|
||||
<legend>{legend}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
56
gui-ts/src/ui/common/NumberField.tsx
Normal file
56
gui-ts/src/ui/common/NumberField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type NumberFieldProps = {
|
||||
id?: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Controlled numeric input that keeps a local string buffer so users can
|
||||
* type partial values (e.g. "-", "0.", "1e") without being clobbered.
|
||||
*/
|
||||
export function NumberField(props: NumberFieldProps) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const [buffer, setBuffer] = useState<string>(Number.isFinite(value) ? String(value) : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
setBuffer((prev) => {
|
||||
const parsed = Number(prev);
|
||||
if (Number.isFinite(parsed) && parsed === value) return prev;
|
||||
return String(value);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="panel-input"
|
||||
aria-label={rest.ariaLabel}
|
||||
id={rest.id}
|
||||
step={rest.step ?? "any"}
|
||||
min={rest.min}
|
||||
max={rest.max}
|
||||
placeholder={rest.placeholder}
|
||||
disabled={rest.disabled}
|
||||
value={buffer}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setBuffer(next);
|
||||
const parsed = Number(next);
|
||||
if (next === "") {
|
||||
onChange(0);
|
||||
} else if (Number.isFinite(parsed)) {
|
||||
onChange(parsed);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
gui-ts/src/ui/common/RadioGroup.tsx
Normal file
29
gui-ts/src/ui/common/RadioGroup.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export type RadioOption<V extends string> = { value: V; label: string };
|
||||
|
||||
export type RadioGroupProps<V extends string> = {
|
||||
name: string;
|
||||
value: V;
|
||||
onChange: (value: V) => void;
|
||||
options: Array<RadioOption<V>>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function RadioGroup<V extends string>(props: RadioGroupProps<V>) {
|
||||
return (
|
||||
<div className="panel-radio-group" role="radiogroup">
|
||||
{props.options.map((opt) => (
|
||||
<label key={opt.value} className="panel-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={props.name}
|
||||
value={opt.value}
|
||||
checked={props.value === opt.value}
|
||||
disabled={props.disabled}
|
||||
onChange={() => props.onChange(opt.value)}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
gui-ts/src/ui/common/Row.tsx
Normal file
18
gui-ts/src/ui/common/Row.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
export function Row({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children
|
||||
}: PropsWithChildren<{ label: ReactNode; htmlFor?: string; hint?: ReactNode }>) {
|
||||
return (
|
||||
<div className="panel-row">
|
||||
<label htmlFor={htmlFor}>{label}</label>
|
||||
<div className="panel-row-input">
|
||||
{children}
|
||||
{hint ? <div className="panel-row-hint">{hint}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
gui-ts/src/ui/common/SelectField.tsx
Normal file
40
gui-ts/src/ui/common/SelectField.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
export type SelectOption<V extends string | number> = {
|
||||
value: V;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SelectFieldProps<V extends string | number> = {
|
||||
id?: string;
|
||||
value: V;
|
||||
onChange: (value: V) => void;
|
||||
options: Array<SelectOption<V>>;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function SelectField<V extends string | number>(props: SelectFieldProps<V>) {
|
||||
return (
|
||||
<select
|
||||
className="panel-input"
|
||||
id={props.id}
|
||||
aria-label={props.ariaLabel}
|
||||
value={String(props.value)}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
const first = props.options[0];
|
||||
if (typeof first?.value === "number") {
|
||||
props.onChange(Number(next) as V);
|
||||
} else {
|
||||
props.onChange(next as V);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.options.map((opt) => (
|
||||
<option key={String(opt.value)} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
23
gui-ts/src/ui/common/TextField.tsx
Normal file
23
gui-ts/src/ui/common/TextField.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export type TextFieldProps = {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function TextField(props: TextFieldProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="panel-input"
|
||||
id={props.id}
|
||||
aria-label={props.ariaLabel}
|
||||
value={props.value}
|
||||
placeholder={props.placeholder}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
gui-ts/src/ui/common/UPlotChart.tsx
Normal file
63
gui-ts/src/ui/common/UPlotChart.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import type { Options, AlignedData } from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
|
||||
export type UPlotChartProps = {
|
||||
data: AlignedData;
|
||||
options: Options;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin React wrapper around uPlot. Re-creates the chart whenever options
|
||||
* identity changes and calls `setData` when data changes in place.
|
||||
*/
|
||||
export function UPlotChart(props: UPlotChartProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return undefined;
|
||||
if (typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent)) {
|
||||
return undefined;
|
||||
}
|
||||
// Skip under jsdom-style environments without a real 2D canvas context.
|
||||
try {
|
||||
const probe = document.createElement("canvas").getContext("2d");
|
||||
if (!probe) return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const opts: Options = {
|
||||
...props.options,
|
||||
width: hostRef.current.clientWidth || props.options.width || 600,
|
||||
height: props.height ?? props.options.height ?? 260
|
||||
};
|
||||
const chart = new uPlot(opts, props.data, hostRef.current);
|
||||
instanceRef.current = chart;
|
||||
|
||||
const resize = () => {
|
||||
if (!hostRef.current || !instanceRef.current) return;
|
||||
instanceRef.current.setSize({
|
||||
width: hostRef.current.clientWidth || 600,
|
||||
height: props.height ?? 260
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
chart.destroy();
|
||||
instanceRef.current = null;
|
||||
};
|
||||
// Intentionally only re-create on options identity change; data updates handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.options, props.height]);
|
||||
|
||||
useEffect(() => {
|
||||
instanceRef.current?.setData(props.data);
|
||||
}, [props.data]);
|
||||
|
||||
return <div ref={hostRef} className="uplot-host" style={{ width: "100%" }} />;
|
||||
}
|
||||
342
gui-ts/src/ui/common/Wellbore3DView.tsx
Normal file
342
gui-ts/src/ui/common/Wellbore3DView.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { CaseState } from "../../state/caseModel";
|
||||
import { DLS_BAD_SECTION_THRESHOLD } from "../../state/engineeringChecks";
|
||||
import {
|
||||
buildTrajectorySegments,
|
||||
interpolateAlongMd,
|
||||
type TrajectoryPoint3D,
|
||||
type TrajectorySegment
|
||||
} from "../../state/trajectoryMetrics";
|
||||
|
||||
type ProjectionMode = "perspective" | "orthographic";
|
||||
export type OverlayMode = "dls" | "sideLoad";
|
||||
|
||||
export type Wellbore3DViewProps = {
|
||||
caseState: CaseState;
|
||||
overlayMode?: OverlayMode;
|
||||
sideLoadProfile?: number[] | null;
|
||||
highlightedSegmentIndex?: number | null;
|
||||
onSegmentSelect?: (segmentIndex: number | null) => void;
|
||||
svgId?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
function colorForDls(dls: number): string {
|
||||
if (dls >= DLS_BAD_SECTION_THRESHOLD) return "#ef4444";
|
||||
if (dls >= DLS_BAD_SECTION_THRESHOLD * 0.5) return "#f59e0b";
|
||||
return "#22c55e";
|
||||
}
|
||||
|
||||
function colorForSideLoad(value: number, max: number): string {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 1e-6) return "#22c55e";
|
||||
const ratio = Math.max(0, Math.min(1, value / max));
|
||||
if (ratio >= 0.85) return "#ef4444";
|
||||
if (ratio >= 0.45) return "#f59e0b";
|
||||
return "#22c55e";
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function colorForRod(t: number): string {
|
||||
const r = Math.round(lerp(56, 217, t));
|
||||
const g = Math.round(lerp(189, 70, t));
|
||||
const b = Math.round(lerp(248, 239, t));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function project(
|
||||
p: TrajectoryPoint3D,
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number; minZ: number; maxZ: number },
|
||||
width: number,
|
||||
height: number,
|
||||
view: {
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
projection: ProjectionMode;
|
||||
}
|
||||
): { x: number; y: number } {
|
||||
const cx = (bounds.minX + bounds.maxX) * 0.5;
|
||||
const cy = (bounds.minY + bounds.maxY) * 0.5;
|
||||
const cz = (bounds.minZ + bounds.maxZ) * 0.5;
|
||||
let x = p.x - cx;
|
||||
let y = p.y - cy;
|
||||
let z = p.z - cz;
|
||||
const yaw = view.yaw;
|
||||
const pitch = view.pitch;
|
||||
const x1 = x * Math.cos(yaw) - y * Math.sin(yaw);
|
||||
const y1 = x * Math.sin(yaw) + y * Math.cos(yaw);
|
||||
const z1 = z;
|
||||
const x2 = x1;
|
||||
const y2 = y1 * Math.cos(pitch) - z1 * Math.sin(pitch);
|
||||
const z2 = y1 * Math.sin(pitch) + z1 * Math.cos(pitch);
|
||||
x = x2;
|
||||
y = y2;
|
||||
z = z2;
|
||||
const extent = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY, bounds.maxZ - bounds.minZ, 1);
|
||||
const scale = ((Math.min(width, height) * 0.72) / extent) * view.zoom;
|
||||
const depth = view.projection === "perspective" ? 1 / (1 + z * 0.001) : 1;
|
||||
return {
|
||||
x: width * 0.5 + view.panX + x * scale * depth,
|
||||
y: height * 0.5 + view.panY - y * scale * depth
|
||||
};
|
||||
}
|
||||
|
||||
export function Wellbore3DView({
|
||||
caseState,
|
||||
overlayMode = "dls",
|
||||
sideLoadProfile = null,
|
||||
highlightedSegmentIndex = null,
|
||||
onSegmentSelect,
|
||||
svgId = "wellbore-3d-svg",
|
||||
width = 840,
|
||||
height = 420
|
||||
}: Wellbore3DViewProps) {
|
||||
const [view, setView] = useState({
|
||||
yaw: 0.8,
|
||||
pitch: 0.55,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
projection: "perspective" as ProjectionMode
|
||||
});
|
||||
const dragState = useRef<{
|
||||
active: boolean;
|
||||
mode: "rotate" | "pan";
|
||||
x: number;
|
||||
y: number;
|
||||
}>({ active: false, mode: "rotate", x: 0, y: 0 });
|
||||
|
||||
const geom = useMemo(() => {
|
||||
const segments = buildTrajectorySegments(caseState.survey);
|
||||
if (!segments.length) return null;
|
||||
const points = [segments[0].a, ...segments.map((s) => s.b)];
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
const zs = points.map((p) => p.z);
|
||||
const bounds = {
|
||||
minX: Math.min(...xs),
|
||||
maxX: Math.max(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxY: Math.max(...ys),
|
||||
minZ: Math.min(...zs),
|
||||
maxZ: Math.max(...zs)
|
||||
};
|
||||
const rodLength = caseState.taper.reduce((sum, row) => sum + Math.max(0, row.length), 0);
|
||||
const pumpPoint = interpolateAlongMd(segments, caseState.pumpDepth);
|
||||
const sideLoadMax = sideLoadProfile?.length
|
||||
? Math.max(...sideLoadProfile.filter((v) => Number.isFinite(v)), 0)
|
||||
: 0;
|
||||
return { segments, bounds, rodLength, pumpPoint, sideLoadMax };
|
||||
}, [caseState, sideLoadProfile]);
|
||||
|
||||
if (!geom) {
|
||||
return <p className="panel-note">Need at least 2 survey stations to render 3D wellbore.</p>;
|
||||
}
|
||||
|
||||
const maxDls = Math.max(...geom.segments.map((segment) => segment.dls), 0);
|
||||
const highDlsCount = geom.segments.filter(
|
||||
(segment) => segment.dls >= DLS_BAD_SECTION_THRESHOLD
|
||||
).length;
|
||||
const totalLen = geom.segments[geom.segments.length - 1].b.md;
|
||||
|
||||
return (
|
||||
<div className="wellbore-3d-wrap">
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
setView((prev) => ({
|
||||
...prev,
|
||||
projection: prev.projection === "perspective" ? "orthographic" : "perspective"
|
||||
}))
|
||||
}
|
||||
>
|
||||
Projection: {view.projection}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setView((prev) => ({ ...prev, zoom: Math.min(prev.zoom * 1.15, 4) }))}
|
||||
>
|
||||
Zoom +
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setView((prev) => ({ ...prev, zoom: Math.max(prev.zoom / 1.15, 0.35) }))}
|
||||
>
|
||||
Zoom -
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
setView({
|
||||
yaw: 0.8,
|
||||
pitch: 0.55,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
projection: "perspective"
|
||||
})
|
||||
}
|
||||
>
|
||||
Reset View
|
||||
</button>
|
||||
<span className="panel-note" style={{ marginLeft: "auto", marginBottom: 0 }}>
|
||||
Drag: rotate | Shift+drag: pan | Mouse wheel: zoom
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
id={svgId}
|
||||
className="wellbore-3d"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label="3D wellbore view"
|
||||
>
|
||||
<title>3D Wellbore Viewer</title>
|
||||
<rect x={0} y={0} width={width} height={height} fill="#020617" stroke="#334155" />
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={width}
|
||||
height={height}
|
||||
fill="transparent"
|
||||
onClick={() => onSegmentSelect?.(null)}
|
||||
onPointerDown={(event) => {
|
||||
dragState.current = {
|
||||
active: true,
|
||||
mode: event.shiftKey ? "pan" : "rotate",
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
(event.currentTarget as SVGRectElement).setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!dragState.current.active) return;
|
||||
const dx = event.clientX - dragState.current.x;
|
||||
const dy = event.clientY - dragState.current.y;
|
||||
dragState.current.x = event.clientX;
|
||||
dragState.current.y = event.clientY;
|
||||
if (dragState.current.mode === "pan") {
|
||||
setView((prev) => ({ ...prev, panX: prev.panX + dx, panY: prev.panY + dy }));
|
||||
} else {
|
||||
setView((prev) => ({
|
||||
...prev,
|
||||
yaw: prev.yaw + dx * 0.006,
|
||||
pitch: Math.max(-1.25, Math.min(1.25, prev.pitch + dy * 0.004))
|
||||
}));
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
dragState.current.active = false;
|
||||
(event.currentTarget as SVGRectElement).releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
dragState.current.active = false;
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
const factor = event.deltaY < 0 ? 1.06 : 1 / 1.06;
|
||||
setView((prev) => ({
|
||||
...prev,
|
||||
zoom: Math.max(0.35, Math.min(4, prev.zoom * factor))
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{geom.segments.map((segment, idx) => {
|
||||
const a = project(segment.a, geom.bounds, width, height, view);
|
||||
const b = project(segment.b, geom.bounds, width, height, view);
|
||||
const sideLoad =
|
||||
sideLoadProfile && sideLoadProfile.length
|
||||
? sideLoadProfile[Math.min(idx, sideLoadProfile.length - 1)] ?? 0
|
||||
: 0;
|
||||
const stroke =
|
||||
overlayMode === "sideLoad"
|
||||
? colorForSideLoad(sideLoad, geom.sideLoadMax)
|
||||
: colorForDls(segment.dls);
|
||||
const active = highlightedSegmentIndex === idx;
|
||||
return (
|
||||
<line
|
||||
key={`tube-${idx}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke={stroke}
|
||||
strokeWidth={active ? 6 : 4}
|
||||
strokeLinecap="round"
|
||||
opacity={active ? 1 : 0.75}
|
||||
onClick={() => onSegmentSelect?.(idx)}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{geom.segments.map((segment, idx) => {
|
||||
const rodEndMd = geom.rodLength;
|
||||
if (segment.a.md > rodEndMd) return null;
|
||||
const clippedEnd = segment.b.md > rodEndMd
|
||||
? interpolateAlongMd([segment], rodEndMd)
|
||||
: segment.b;
|
||||
if (!clippedEnd) return null;
|
||||
const a = project(segment.a, geom.bounds, width, height, view);
|
||||
const b = project(clippedEnd, geom.bounds, width, height, view);
|
||||
const t = Math.min(1, Math.max(0, clippedEnd.md / Math.max(rodEndMd, 1)));
|
||||
return (
|
||||
<line
|
||||
key={`rod-${idx}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke={colorForRod(t)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{geom.pumpPoint && (
|
||||
(() => {
|
||||
const p = project(geom.pumpPoint as TrajectoryPoint3D, geom.bounds, width, height, view);
|
||||
return (
|
||||
<g>
|
||||
<circle cx={p.x} cy={p.y} r={5.5} fill="#e11d48" />
|
||||
<circle cx={p.x} cy={p.y} r={10} fill="none" stroke="#fb7185" strokeWidth={1.4} />
|
||||
</g>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</svg>
|
||||
<div className="wellbore-legend">
|
||||
{overlayMode === "dls" ? (
|
||||
<>
|
||||
<span><i style={{ background: "#22c55e" }} />Low DLS (< {(DLS_BAD_SECTION_THRESHOLD * 0.5).toFixed(1)})</span>
|
||||
<span><i style={{ background: "#f59e0b" }} />Moderate DLS</span>
|
||||
<span><i style={{ background: "#ef4444" }} />Bad section DLS (≥ {DLS_BAD_SECTION_THRESHOLD})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span><i style={{ background: "#22c55e" }} />Low side-load risk</span>
|
||||
<span><i style={{ background: "#f59e0b" }} />Moderate side-load risk</span>
|
||||
<span><i style={{ background: "#ef4444" }} />High side-load risk</span>
|
||||
</>
|
||||
)}
|
||||
<span><i style={{ background: "linear-gradient(90deg,#38bdf8,#d946ef)" }} />Rod string gradient</span>
|
||||
</div>
|
||||
<div className="wellbore-kpis">
|
||||
<span>Max DLS: {maxDls.toFixed(2)} deg/100</span>
|
||||
<span>Bad-DLS segments: {highDlsCount}</span>
|
||||
<span>Total MD: {totalLen.toFixed(1)}</span>
|
||||
<span>Pump MD: {caseState.pumpDepth.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
33
gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx
Normal file
33
gui-ts/src/ui/common/__tests__/Wellbore3DView.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Wellbore3DView } from "../Wellbore3DView";
|
||||
import { EMPTY_CASE_STATE } from "../../../state/caseModel";
|
||||
|
||||
describe("Wellbore3DView controls", () => {
|
||||
it("renders projection/zoom/reset controls and toggles projection", () => {
|
||||
const state = {
|
||||
...EMPTY_CASE_STATE,
|
||||
pumpDepth: 1000,
|
||||
survey: [
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: 500, inc: 15, azi: 35 },
|
||||
{ md: 1000, inc: 30, azi: 65 }
|
||||
],
|
||||
taper: [{ diameter: 19.05, length: 1000, modulus: 30.5, rodType: 3 }]
|
||||
};
|
||||
|
||||
render(<Wellbore3DView caseState={state} />);
|
||||
const projectionBtn = screen.getByRole("button", { name: /Projection:/i });
|
||||
expect(projectionBtn).toHaveTextContent("Projection: perspective");
|
||||
|
||||
fireEvent.click(projectionBtn);
|
||||
expect(screen.getByRole("button", { name: /Projection:/i })).toHaveTextContent(
|
||||
"Projection: orthographic"
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Zoom +" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Zoom -" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reset View" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
140
gui-ts/src/ui/tabs/AdvancedTab.tsx
Normal file
140
gui-ts/src/ui/tabs/AdvancedTab.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { serializeCaseXml } from "../../state/xmlExport";
|
||||
import { hydrateFromParsed } from "../../state/xmlImport";
|
||||
import { parseCaseXmlApi } from "../../api/client";
|
||||
import { textOf, describeRawField } from "./rawFieldHelpers";
|
||||
|
||||
type Props = {
|
||||
store: CaseStore;
|
||||
};
|
||||
|
||||
export function AdvancedTab({ store }: Props) {
|
||||
const { state, setState } = store;
|
||||
const [pasted, setPasted] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function importXml(xml: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const parsed = await parseCaseXmlApi(xml);
|
||||
const next = hydrateFromParsed(parsed);
|
||||
setState(next);
|
||||
setMessage(
|
||||
`Imported case with ${Object.keys(parsed.rawFields).length} XML fields (${parsed.unsupportedFields.length} unsupported).`
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(file: File) {
|
||||
const text = await file.text();
|
||||
setPasted(text);
|
||||
void importXml(text);
|
||||
}
|
||||
|
||||
function exportXml() {
|
||||
const xml = serializeCaseXml(state);
|
||||
const blob = new Blob([xml], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
const safeName = (state.wellName || "case").replace(/[^a-z0-9_.-]+/gi, "_");
|
||||
anchor.download = `${safeName || "case"}.xml`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage("Exported current state as XML.");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fieldset legend="Import Case XML">
|
||||
<p className="panel-note">
|
||||
Upload a case XML file or paste its contents. Parsing is performed by
|
||||
<code> POST /case/parse</code> in the solver-api so the result
|
||||
matches the canonical parser exactly.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<label className="btn btn-secondary">
|
||||
Choose file…
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void onFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !pasted.trim()}
|
||||
onClick={() => void importXml(pasted)}
|
||||
>
|
||||
Parse pasted XML
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={exportXml}>
|
||||
Export current state as XML
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="advanced-textarea"
|
||||
placeholder="<INPRoot>…</INPRoot>"
|
||||
value={pasted}
|
||||
onChange={(e) => setPasted(e.target.value)}
|
||||
rows={14}
|
||||
/>
|
||||
{message && <div className="callout callout-info">{message}</div>}
|
||||
{error && <div className="callout callout-error">{error}</div>}
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset
|
||||
legend={`Raw XML fields (${state.rawFieldOrder.length})`}
|
||||
>
|
||||
<p className="panel-note">
|
||||
Every element from the loaded XML is stored here and round-tripped on
|
||||
export. Fields with a first-class editor in another tab are shown
|
||||
read-only.
|
||||
</p>
|
||||
<div className="table-scroll" style={{ maxHeight: 360 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.rawFieldOrder.map((key) => (
|
||||
<tr key={key}>
|
||||
<td style={{ fontFamily: "monospace" }}>{key}</td>
|
||||
<td title={describeRawField(state.rawFields[key])}>
|
||||
<code>{truncate(textOf(state.rawFields[key]))}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 120): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max - 1)}…`;
|
||||
}
|
||||
57
gui-ts/src/ui/tabs/FluidTab.tsx
Normal file
57
gui-ts/src/ui/tabs/FluidTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function FluidTab({ store }: Props) {
|
||||
const { state, update } = store;
|
||||
return (
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Fluid Properties">
|
||||
<Row label="Water Cut (%)" htmlFor="waterCut">
|
||||
<NumberField
|
||||
id="waterCut"
|
||||
value={state.waterCut}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => update("waterCut", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Water Specific Gravity" htmlFor="waterSG">
|
||||
<NumberField
|
||||
id="waterSG"
|
||||
value={state.waterSpecGravity}
|
||||
step={0.001}
|
||||
onChange={(v) => update("waterSpecGravity", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Oil API Gravity" htmlFor="oilAPI">
|
||||
<NumberField
|
||||
id="oilAPI"
|
||||
value={state.fluidLevelOilGravity}
|
||||
step={1}
|
||||
onChange={(v) => update("fluidLevelOilGravity", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Tubing Hydraulics">
|
||||
<Row
|
||||
label="Tubing Gradient"
|
||||
htmlFor="tubingGrad"
|
||||
hint="psi/ft in imperial units; converted to Pa/m internally"
|
||||
>
|
||||
<NumberField
|
||||
id="tubingGrad"
|
||||
value={state.tubingGradient}
|
||||
step={0.01}
|
||||
onChange={(v) => update("tubingGradient", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
gui-ts/src/ui/tabs/KinematicsTab.tsx
Normal file
119
gui-ts/src/ui/tabs/KinematicsTab.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { TextField } from "../common/TextField";
|
||||
import { SelectField } from "../common/SelectField";
|
||||
|
||||
type Props = {
|
||||
store: CaseStore;
|
||||
surfaceCardText: string;
|
||||
onSurfaceCardTextChange: (value: string) => void;
|
||||
onValidateSurfaceCard: () => void;
|
||||
validatingSurfaceCard: boolean;
|
||||
surfaceCardQaMessage: string | null;
|
||||
surfaceCardQaError: string | null;
|
||||
};
|
||||
|
||||
export function KinematicsTab({
|
||||
store,
|
||||
surfaceCardText,
|
||||
onSurfaceCardTextChange,
|
||||
onValidateSurfaceCard,
|
||||
validatingSurfaceCard,
|
||||
surfaceCardQaMessage,
|
||||
surfaceCardQaError
|
||||
}: Props) {
|
||||
const { state, update } = store;
|
||||
return (
|
||||
<>
|
||||
<Fieldset legend="Pumping Unit / Surface Motion">
|
||||
<p className="panel-note">
|
||||
Drives the polished-rod boundary condition via pumping speed (SPM).
|
||||
Diagnostic workflow with measured surface-card upload is planned for
|
||||
a future pass (see <code>solver-api POST /solve/validate-card</code>).
|
||||
</p>
|
||||
<Row label="Pumping Speed (SPM)" htmlFor="pumpingSpeed">
|
||||
<NumberField
|
||||
id="pumpingSpeed"
|
||||
value={state.pumpingSpeed}
|
||||
step={0.1}
|
||||
min={0.5}
|
||||
onChange={(v) => update("pumpingSpeed", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Pumping Speed Option" htmlFor="pumpingSpeedOption">
|
||||
<SelectField
|
||||
id="pumpingSpeedOption"
|
||||
value={state.pumpingSpeedOption}
|
||||
onChange={(v) => update("pumpingSpeedOption", v)}
|
||||
options={[
|
||||
{ value: 0, label: "0 — legacy / unknown" },
|
||||
{ value: 1, label: "1 — fixed SPM" },
|
||||
{ value: 2, label: "2 — VFD-driven" }
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Pumping Unit ID" htmlFor="pumpingUnitId">
|
||||
<TextField
|
||||
id="pumpingUnitId"
|
||||
value={state.pumpingUnitId}
|
||||
onChange={(v) => update("pumpingUnitId", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Stroke Timing">
|
||||
<Row label="% Upstroke time" htmlFor="percentUp">
|
||||
<NumberField
|
||||
id="percentUp"
|
||||
value={state.percentUpstrokeTime}
|
||||
step={1}
|
||||
min={10}
|
||||
max={90}
|
||||
onChange={(v) => update("percentUpstrokeTime", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="% Downstroke time" htmlFor="percentDown">
|
||||
<NumberField
|
||||
id="percentDown"
|
||||
value={state.percentDownstrokeTime}
|
||||
step={1}
|
||||
min={10}
|
||||
max={90}
|
||||
onChange={(v) => update("percentDownstrokeTime", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
<Fieldset legend="Diagnostic Surface Card Input">
|
||||
<p className="panel-note">
|
||||
Paste measured card rows as <code>position,load</code> pairs (one per line).
|
||||
Example:
|
||||
<br />
|
||||
<code>-1.2,12000</code>
|
||||
<br />
|
||||
<code>-0.6,13200</code>
|
||||
</p>
|
||||
<textarea
|
||||
className="advanced-textarea"
|
||||
rows={8}
|
||||
value={surfaceCardText}
|
||||
onChange={(e) => onSurfaceCardTextChange(e.target.value)}
|
||||
placeholder="-1.2,12000 -0.6,13200 ..."
|
||||
/>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={validatingSurfaceCard || !surfaceCardText.trim()}
|
||||
onClick={onValidateSurfaceCard}
|
||||
>
|
||||
{validatingSurfaceCard ? "Validating…" : "Validate Surface Card"}
|
||||
</button>
|
||||
</div>
|
||||
{surfaceCardQaMessage && <div className="callout callout-info">{surfaceCardQaMessage}</div>}
|
||||
{surfaceCardQaError && <div className="callout callout-error">{surfaceCardQaError}</div>}
|
||||
</Fieldset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
gui-ts/src/ui/tabs/PumpTab.tsx
Normal file
70
gui-ts/src/ui/tabs/PumpTab.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { SelectField } from "../common/SelectField";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function PumpTab({ store }: Props) {
|
||||
const { state, update } = store;
|
||||
return (
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Pump Geometry">
|
||||
<Row
|
||||
label="Plunger Diameter"
|
||||
htmlFor="plungerDiam"
|
||||
hint="mm in base-case XML (converted to m if > 2)"
|
||||
>
|
||||
<NumberField
|
||||
id="plungerDiam"
|
||||
value={state.pumpDiameter}
|
||||
step={0.25}
|
||||
onChange={(v) => update("pumpDiameter", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Pump Friction" htmlFor="pumpFric" hint="lbf (imperial)">
|
||||
<NumberField
|
||||
id="pumpFric"
|
||||
value={state.pumpFriction}
|
||||
step={10}
|
||||
onChange={(v) => update("pumpFriction", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Pump Intake Pressure" htmlFor="pumpIntake" hint="psi (imperial)">
|
||||
<NumberField
|
||||
id="pumpIntake"
|
||||
value={state.pumpIntakePressure}
|
||||
step={1}
|
||||
onChange={(v) => update("pumpIntakePressure", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Fillage">
|
||||
<Row label="Pump Fillage Option" htmlFor="fillageOpt">
|
||||
<SelectField
|
||||
id="fillageOpt"
|
||||
value={state.pumpFillageOption}
|
||||
onChange={(v) => update("pumpFillageOption", v)}
|
||||
options={[
|
||||
{ value: 0, label: "0 — auto" },
|
||||
{ value: 1, label: "1 — specified" },
|
||||
{ value: 2, label: "2 — incomplete fillage" }
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Percent Pump Fillage (%)" htmlFor="pctFill">
|
||||
<NumberField
|
||||
id="pctFill"
|
||||
value={state.percentPumpFillage}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => update("percentPumpFillage", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
536
gui-ts/src/ui/tabs/ResultsTab.tsx
Normal file
536
gui-ts/src/ui/tabs/ResultsTab.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Options, AlignedData } from "uplot";
|
||||
import type { SolveResponse, SolverOutput } from "../../types";
|
||||
import type { CaseState } from "../../state/caseModel";
|
||||
import type { EngineeringChecks } from "../../state/engineeringChecks";
|
||||
import { DLS_BAD_SECTION_THRESHOLD } from "../../state/engineeringChecks";
|
||||
import { buildTrajectorySegments } from "../../state/trajectoryMetrics";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { UPlotChart } from "../common/UPlotChart";
|
||||
import { Wellbore3DView } from "../common/Wellbore3DView";
|
||||
import type { OverlayMode } from "../common/Wellbore3DView";
|
||||
|
||||
type Props = {
|
||||
result: SolveResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastRunAt: string | null;
|
||||
elapsedSeconds: number | null;
|
||||
caseState: CaseState;
|
||||
checks: EngineeringChecks;
|
||||
onNavigateTab?: (tabId: string) => void;
|
||||
};
|
||||
|
||||
type CardSeries = {
|
||||
position: number[];
|
||||
polished: number[];
|
||||
downhole: number[];
|
||||
};
|
||||
|
||||
function toSeries(solver: SolverOutput | undefined): CardSeries | null {
|
||||
if (!solver || !solver.card?.length) return null;
|
||||
return {
|
||||
position: solver.card.map((p) => p.position),
|
||||
polished: solver.card.map((p) => p.polishedLoad),
|
||||
downhole: solver.card.map((p) => p.downholeLoad)
|
||||
};
|
||||
}
|
||||
|
||||
function formatKn(value: number | undefined): string {
|
||||
if (value === undefined || !Number.isFinite(value)) return "—";
|
||||
return (value / 1000).toFixed(2);
|
||||
}
|
||||
|
||||
function computePumpPlacement(caseState: CaseState) {
|
||||
const segments = buildTrajectorySegments(caseState.survey);
|
||||
const taperLen = caseState.taper.reduce((sum, row) => sum + Math.max(0, row.length), 0);
|
||||
if (!segments.length) {
|
||||
return {
|
||||
nearestStationIndex: -1,
|
||||
nearestStationMd: null as number | null,
|
||||
nearestStationDistance: null as number | null,
|
||||
surveyEndMd: null as number | null,
|
||||
surveyToPumpDelta: null as number | null,
|
||||
rodToPumpDelta: taperLen - caseState.pumpDepth
|
||||
};
|
||||
}
|
||||
const stationMds = [segments[0].a.md, ...segments.map((s) => s.b.md)];
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Math.abs(stationMds[0] - caseState.pumpDepth);
|
||||
for (let i = 1; i < stationMds.length; i += 1) {
|
||||
const d = Math.abs(stationMds[i] - caseState.pumpDepth);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
const surveyEndMd = stationMds[stationMds.length - 1];
|
||||
return {
|
||||
nearestStationIndex: nearestIdx,
|
||||
nearestStationMd: stationMds[nearestIdx],
|
||||
nearestStationDistance: nearestDist,
|
||||
surveyEndMd,
|
||||
surveyToPumpDelta: surveyEndMd - caseState.pumpDepth,
|
||||
rodToPumpDelta: taperLen - caseState.pumpDepth
|
||||
};
|
||||
}
|
||||
|
||||
export function ResultsTab({
|
||||
result,
|
||||
loading,
|
||||
error,
|
||||
lastRunAt,
|
||||
elapsedSeconds,
|
||||
caseState,
|
||||
checks,
|
||||
onNavigateTab
|
||||
}: Props) {
|
||||
const primary = result?.solver;
|
||||
const fea = result?.solvers?.fea ?? null;
|
||||
const fdm = result?.solvers?.fdm ?? primary ?? null;
|
||||
const [overlayMode, setOverlayMode] = useState<OverlayMode>("dls");
|
||||
const [selectedSegment, setSelectedSegment] = useState<number | null>(null);
|
||||
const [badOnly, setBadOnly] = useState(false);
|
||||
const trajectorySegments = useMemo(() => buildTrajectorySegments(caseState.survey), [caseState.survey]);
|
||||
const filteredSegments = useMemo(
|
||||
() =>
|
||||
badOnly
|
||||
? trajectorySegments.filter((segment) => segment.dls >= DLS_BAD_SECTION_THRESHOLD)
|
||||
: trajectorySegments,
|
||||
[badOnly, trajectorySegments]
|
||||
);
|
||||
const sideLoadProfile = primary?.profiles?.sideLoadProfile ?? null;
|
||||
const pumpDiag = useMemo(() => computePumpPlacement(caseState), [caseState]);
|
||||
|
||||
const dynacardData = useMemo<AlignedData | null>(() => {
|
||||
const fdmSeries = toSeries(fdm ?? undefined);
|
||||
const feaSeries = toSeries(fea ?? undefined);
|
||||
if (!fdmSeries) return null;
|
||||
if (feaSeries && feaSeries.position.length === fdmSeries.position.length) {
|
||||
return [
|
||||
fdmSeries.position,
|
||||
fdmSeries.polished,
|
||||
fdmSeries.downhole,
|
||||
feaSeries.polished,
|
||||
feaSeries.downhole
|
||||
] as AlignedData;
|
||||
}
|
||||
return [fdmSeries.position, fdmSeries.polished, fdmSeries.downhole] as AlignedData;
|
||||
}, [fdm, fea]);
|
||||
|
||||
const dynacardOptions = useMemo<Options>(() => {
|
||||
const seriesCount = fea ? 5 : 3;
|
||||
const seriesSpec: Options["series"] = [
|
||||
{ label: "Position (m)" },
|
||||
{ label: "Polished (FDM)", stroke: "#f59e0b", width: 2 },
|
||||
{ label: "Downhole (FDM)", stroke: "#22d3ee", width: 2 }
|
||||
];
|
||||
if (seriesCount === 5) {
|
||||
seriesSpec.push({ label: "Polished (FEA)", stroke: "#f43f5e", width: 1.5, dash: [6, 3] });
|
||||
seriesSpec.push({ label: "Downhole (FEA)", stroke: "#34d399", width: 1.5, dash: [6, 3] });
|
||||
}
|
||||
return {
|
||||
width: 800,
|
||||
height: 320,
|
||||
scales: { x: { time: false } },
|
||||
axes: [
|
||||
{ label: "Polished-rod position (m)", stroke: "#cbd5f5" },
|
||||
{ label: "Load (N)", stroke: "#cbd5f5" }
|
||||
],
|
||||
series: seriesSpec,
|
||||
legend: { show: true }
|
||||
} satisfies Options;
|
||||
}, [fea]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="callout callout-info">
|
||||
<span className="spinner" /> Running simulation…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="callout callout-error">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !loading && !error && (
|
||||
<div className="callout">
|
||||
No results yet. Edit inputs and press <strong>Run Solver</strong>.
|
||||
</div>
|
||||
)}
|
||||
{!!checks.issues.length && (
|
||||
<Fieldset legend="Input Integrity Checks">
|
||||
<ul className="warning-list">
|
||||
{checks.issues.map((issue) => (
|
||||
<li key={issue.code}>
|
||||
<strong>{issue.severity.toUpperCase()}:</strong> {issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Fieldset>
|
||||
)}
|
||||
<Fieldset legend="3D Wellbore / Rod String / Pump">
|
||||
<p className="panel-note">
|
||||
Tubing trajectory is colored by dogleg severity (DLS). Rod string is overlaid with a
|
||||
depth gradient, and pump location is marked in red.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${overlayMode === "dls" ? "btn-primary" : ""}`}
|
||||
onClick={() => setOverlayMode("dls")}
|
||||
aria-pressed={overlayMode === "dls"}
|
||||
>
|
||||
Overlay: DLS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${overlayMode === "sideLoad" ? "btn-primary" : ""}`}
|
||||
onClick={() => setOverlayMode("sideLoad")}
|
||||
disabled={!sideLoadProfile?.length}
|
||||
title={!sideLoadProfile?.length ? "Run with profile output to enable side-load overlay" : ""}
|
||||
aria-pressed={overlayMode === "sideLoad"}
|
||||
>
|
||||
Overlay: Side-load risk
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setSelectedSegment(null)}
|
||||
disabled={selectedSegment === null}
|
||||
title="Clear selected trajectory segment highlight"
|
||||
>
|
||||
Clear segment highlight
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => exportSvg("wellbore-3d-svg", `${caseState.wellName || "well"}_wellbore.svg`)}
|
||||
title="Export the current 3D viewer as SVG"
|
||||
>
|
||||
Export 3D SVG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
exportSvgToPng("wellbore-3d-svg", `${caseState.wellName || "well"}_wellbore.png`)
|
||||
}
|
||||
title="Export the current 3D viewer as PNG"
|
||||
>
|
||||
Export 3D PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
exportSummaryJson(
|
||||
`${caseState.wellName || "well"}_summary.json`,
|
||||
result,
|
||||
checks,
|
||||
pumpDiag
|
||||
)
|
||||
}
|
||||
title="Export current checks, diagnostics, and run metadata"
|
||||
>
|
||||
Export summary JSON
|
||||
</button>
|
||||
</div>
|
||||
<Wellbore3DView
|
||||
caseState={caseState}
|
||||
overlayMode={overlayMode}
|
||||
sideLoadProfile={sideLoadProfile}
|
||||
highlightedSegmentIndex={selectedSegment}
|
||||
onSegmentSelect={setSelectedSegment}
|
||||
svgId="wellbore-3d-svg"
|
||||
/>
|
||||
</Fieldset>
|
||||
<Fieldset legend="Trajectory Analytics (Segment DLS)">
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${badOnly ? "btn-primary" : ""}`}
|
||||
onClick={() => setBadOnly((v) => !v)}
|
||||
>
|
||||
{badOnly ? "Showing bad segments only" : "Show only bad segments"}
|
||||
</button>
|
||||
<span className="panel-note" style={{ marginLeft: "auto" }}>
|
||||
Click row or 3D segment to cross-highlight.
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-scroll" style={{ maxHeight: 250 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>MD start</th>
|
||||
<th>MD end</th>
|
||||
<th>ΔMD</th>
|
||||
<th>DLS (deg/100)</th>
|
||||
<th>Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSegments.map((segment) => {
|
||||
const severity =
|
||||
segment.dls >= DLS_BAD_SECTION_THRESHOLD
|
||||
? "bad"
|
||||
: segment.dls >= DLS_BAD_SECTION_THRESHOLD * 0.5
|
||||
? "moderate"
|
||||
: "low";
|
||||
return (
|
||||
<tr
|
||||
key={segment.index}
|
||||
className={selectedSegment === segment.index ? "row-selected" : ""}
|
||||
onClick={() => setSelectedSegment(segment.index)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedSegment(segment.index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select trajectory segment ${segment.index + 1}`}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<td>{segment.index + 1}</td>
|
||||
<td>{segment.a.md.toFixed(1)}</td>
|
||||
<td>{segment.b.md.toFixed(1)}</td>
|
||||
<td>{segment.dMd.toFixed(1)}</td>
|
||||
<td>{segment.dls.toFixed(2)}</td>
|
||||
<td>{severity}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!filteredSegments.length && (
|
||||
<tr>
|
||||
<td colSpan={6} className="empty-row">No trajectory segments to display.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Fieldset>
|
||||
<Fieldset legend="Pump Placement Diagnostics">
|
||||
<div className="kpi-grid">
|
||||
<Kpi
|
||||
label="Nearest station index"
|
||||
value={pumpDiag.nearestStationIndex >= 0 ? String(pumpDiag.nearestStationIndex + 1) : "—"}
|
||||
/>
|
||||
<Kpi
|
||||
label="Nearest station MD"
|
||||
value={pumpDiag.nearestStationMd !== null ? pumpDiag.nearestStationMd.toFixed(1) : "—"}
|
||||
/>
|
||||
<Kpi
|
||||
label="Pump-to-nearest station ΔMD"
|
||||
value={
|
||||
pumpDiag.nearestStationDistance !== null
|
||||
? `${pumpDiag.nearestStationDistance.toFixed(1)}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Kpi
|
||||
label="Survey end - pump ΔMD"
|
||||
value={pumpDiag.surveyToPumpDelta !== null ? pumpDiag.surveyToPumpDelta.toFixed(1) : "—"}
|
||||
/>
|
||||
<Kpi label="Rod total - pump Δ" value={pumpDiag.rodToPumpDelta.toFixed(1)} />
|
||||
<Kpi
|
||||
label="Tubing anchor - pump Δ"
|
||||
value={(caseState.tubingAnchorLocation - caseState.pumpDepth).toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-trajectory")}>
|
||||
Go to Trajectory tab
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-rod")}>
|
||||
Go to Rod String tab
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-well")}>
|
||||
Go to Well tab
|
||||
</button>
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Fieldset legend="Summary — Key Performance Indicators">
|
||||
<div className="kpi-grid">
|
||||
<Kpi label="Active solver" value={(result.runMetadata.solverModel || "fdm").toUpperCase()} />
|
||||
<Kpi label="Workflow" value={result.runMetadata.workflow ?? "predictive"} />
|
||||
<Kpi label="Schema" value={`v${result.schemaVersion ?? 2}`} />
|
||||
<Kpi label="Last run" value={lastRunAt ?? "—"} />
|
||||
<Kpi label="Elapsed" value={elapsedSeconds !== null ? `${elapsedSeconds.toFixed(1)} s` : "—"} />
|
||||
<Kpi label="Well" value={result.parsed.model.wellName} />
|
||||
<Kpi label="Surface peak (kN)" value={formatKn(primary?.maxPolishedLoad)} />
|
||||
<Kpi label="Surface min (kN)" value={formatKn(primary?.minPolishedLoad)} />
|
||||
<Kpi label="Downhole peak (kN)" value={formatKn(primary?.maxDownholeLoad)} />
|
||||
<Kpi label="Downhole min (kN)" value={formatKn(primary?.minDownholeLoad)} />
|
||||
<Kpi label="Point count" value={String(primary?.pointCount ?? "—")} />
|
||||
<Kpi
|
||||
label="Gas interference"
|
||||
value={primary?.gasInterference ? "Yes" : "No"}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Dynamometer Card">
|
||||
{dynacardData ? (
|
||||
<UPlotChart data={dynacardData} options={dynacardOptions} height={340} />
|
||||
) : (
|
||||
<p className="panel-note">No card data in response.</p>
|
||||
)}
|
||||
</Fieldset>
|
||||
|
||||
{result.comparison && (
|
||||
<Fieldset legend="FDM vs FEA Comparison">
|
||||
<div className="kpi-grid">
|
||||
<Kpi
|
||||
label="ΔPolished max (N)"
|
||||
value={result.comparison.polishedMaxDelta.toFixed(2)}
|
||||
/>
|
||||
<Kpi
|
||||
label="ΔPolished min (N)"
|
||||
value={result.comparison.polishedMinDelta.toFixed(2)}
|
||||
/>
|
||||
<Kpi
|
||||
label="ΔDownhole max (N)"
|
||||
value={result.comparison.downholeMaxDelta.toFixed(2)}
|
||||
/>
|
||||
<Kpi
|
||||
label="ΔDownhole min (N)"
|
||||
value={result.comparison.downholeMinDelta.toFixed(2)}
|
||||
/>
|
||||
<Kpi
|
||||
label="Residual RMS (N)"
|
||||
value={result.comparison.residualSummary?.rms.toFixed(2) ?? "—"}
|
||||
/>
|
||||
<Kpi
|
||||
label="Residual points"
|
||||
value={String(result.comparison.residualSummary?.points ?? "—")}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{(result.parseWarnings?.length || primary?.warnings?.length) && (
|
||||
<Fieldset legend="Warnings">
|
||||
<ul className="warning-list">
|
||||
{(result.parseWarnings ?? []).map((w, i) => (
|
||||
<li key={`pw-${i}`}>{w}</li>
|
||||
))}
|
||||
{(primary?.warnings ?? []).map((w, i) => (
|
||||
<li key={`sw-${i}`}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{result.parsed.unsupportedFields?.length ? (
|
||||
<Fieldset legend={`Unsupported XML fields (${result.parsed.unsupportedFields.length})`}>
|
||||
<p className="panel-note">
|
||||
Preserved on export; not consumed by the solver.
|
||||
</p>
|
||||
<pre className="mono-block">
|
||||
{result.parsed.unsupportedFields.join("\n")}
|
||||
</pre>
|
||||
</Fieldset>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function exportSvg(svgId: string, filename: string) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
const serializer = new XMLSerializer();
|
||||
const source = serializer.serializeToString(svg);
|
||||
const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportSvgToPng(svgId: string, filename: string) {
|
||||
const svg = document.getElementById(svgId) as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
const serializer = new XMLSerializer();
|
||||
const source = serializer.serializeToString(svg);
|
||||
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const width = vb.width || svg.clientWidth || 800;
|
||||
const height = vb.height || svg.clientHeight || 400;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const pngUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = pngUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
}, "image/png");
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function exportSummaryJson(
|
||||
filename: string,
|
||||
result: SolveResponse | null,
|
||||
checks: EngineeringChecks,
|
||||
pumpDiag: ReturnType<typeof computePumpPlacement>
|
||||
) {
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
checks,
|
||||
pumpPlacement: pumpDiag,
|
||||
runMetadata: result?.runMetadata ?? null,
|
||||
comparison: result?.comparison ?? null,
|
||||
warnings: {
|
||||
parse: result?.parseWarnings ?? [],
|
||||
solver: result?.solver?.warnings ?? []
|
||||
}
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function Kpi({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="kpi-cell">
|
||||
<div className="kpi-label">{label}</div>
|
||||
<div className="kpi-val">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
gui-ts/src/ui/tabs/RodStringTab.tsx
Normal file
130
gui-ts/src/ui/tabs/RodStringTab.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { SelectField } from "../common/SelectField";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
const ROD_TYPE_OPTIONS = [
|
||||
{ value: 0, label: "0 — steel (generic)" },
|
||||
{ value: 1, label: "1 — steel (alt. grade)" },
|
||||
{ value: 2, label: "2 — sinker bar" },
|
||||
{ value: 3, label: "3 — fiberglass" }
|
||||
];
|
||||
|
||||
export function RodStringTab({ store }: Props) {
|
||||
const { state, addTaperRow, removeTaperRow, updateTaperRow, setTaper } = store;
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const nonZero = state.taper.filter(
|
||||
(row) => row.diameter > 0 && row.length > 0
|
||||
);
|
||||
const length = nonZero.reduce((acc, r) => acc + r.length, 0);
|
||||
return { sections: nonZero.length, length };
|
||||
}, [state.taper]);
|
||||
|
||||
function loadDefaultString() {
|
||||
setTaper([
|
||||
{ diameter: 22.225, length: 86, modulus: 30.5, rodType: 3 },
|
||||
{ diameter: 19.05, length: 86, modulus: 30.5, rodType: 3 },
|
||||
{ diameter: 38.1, length: 10, modulus: 30.5, rodType: 2 },
|
||||
{ diameter: 19.05, length: 36, modulus: 30.5, rodType: 3 },
|
||||
{ diameter: 19.05, length: 9, modulus: 30.5, rodType: 3 }
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fieldset legend="Rod String Taper Sections">
|
||||
<p className="panel-note">
|
||||
Define taper sections from the top (surface) to the bottom (pump). The
|
||||
solver treats diameter values > 2 as millimetres and converts to SI.
|
||||
Modulus ≥ 1e8 is treated as Pa; otherwise as Mpsi.
|
||||
</p>
|
||||
|
||||
<div className="button-row">
|
||||
<button type="button" className="btn" onClick={() => addTaperRow()}>
|
||||
Add Section
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setTaper([])}>
|
||||
Clear All
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={loadDefaultString}>
|
||||
Load Base-Case String
|
||||
</button>
|
||||
<span className="panel-note" style={{ marginLeft: "auto" }}>
|
||||
{totals.sections} active section{totals.sections === 1 ? "" : "s"} ·
|
||||
total length {totals.length.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="table-scroll">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>#</th>
|
||||
<th>Diameter</th>
|
||||
<th>Length</th>
|
||||
<th>Modulus</th>
|
||||
<th>Rod Type</th>
|
||||
<th style={{ width: 60 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.taper.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.diameter}
|
||||
onChange={(v) => updateTaperRow(i, { diameter: v })}
|
||||
ariaLabel={`Taper ${i + 1} diameter`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.length}
|
||||
onChange={(v) => updateTaperRow(i, { length: v })}
|
||||
ariaLabel={`Taper ${i + 1} length`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.modulus}
|
||||
step={0.1}
|
||||
onChange={(v) => updateTaperRow(i, { modulus: v })}
|
||||
ariaLabel={`Taper ${i + 1} modulus`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<SelectField
|
||||
value={row.rodType}
|
||||
options={ROD_TYPE_OPTIONS}
|
||||
onChange={(v) => updateTaperRow(i, { rodType: v })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => removeTaperRow(i)}
|
||||
aria-label={`Remove taper ${i + 1}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{state.taper.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="empty-row">
|
||||
No taper sections. Add rows or load the base-case string.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
171
gui-ts/src/ui/tabs/SolverTab.tsx
Normal file
171
gui-ts/src/ui/tabs/SolverTab.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import type { RunSettings } from "../../state/caseModel";
|
||||
import type { EngineeringChecks } from "../../state/engineeringChecks";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { RadioGroup } from "../common/RadioGroup";
|
||||
|
||||
type Props = {
|
||||
store: CaseStore;
|
||||
runSettings: RunSettings;
|
||||
onRunSettingsChange: (next: RunSettings) => void;
|
||||
onRun: () => void;
|
||||
onExportXml: () => void;
|
||||
loading: boolean;
|
||||
checks: EngineeringChecks;
|
||||
};
|
||||
|
||||
export function SolverTab({
|
||||
store,
|
||||
runSettings,
|
||||
onRunSettingsChange,
|
||||
onRun,
|
||||
onExportXml,
|
||||
loading,
|
||||
checks
|
||||
}: Props) {
|
||||
const { state, update } = store;
|
||||
const warningIssues = checks.issues.filter((issue) => issue.severity === "warning");
|
||||
const errorIssues = checks.issues.filter((issue) => issue.severity === "error");
|
||||
return (
|
||||
<>
|
||||
{!!checks.issues.length && (
|
||||
<Fieldset legend="Engineering Checks">
|
||||
{!!errorIssues.length && (
|
||||
<div className="callout callout-error" style={{ marginBottom: warningIssues.length ? 8 : 0 }}>
|
||||
<strong>Blocking errors ({errorIssues.length})</strong>
|
||||
<ul className="warning-list">
|
||||
{errorIssues.map((issue) => (
|
||||
<li key={issue.code}>{issue.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{!!warningIssues.length && (
|
||||
<div className="callout callout-warning">
|
||||
<strong>Warnings ({warningIssues.length})</strong>
|
||||
<ul className="warning-list">
|
||||
{warningIssues.map((issue) => (
|
||||
<li key={issue.code}>{issue.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fieldset>
|
||||
)}
|
||||
<Fieldset legend="Solver Selection">
|
||||
<p className="panel-note">
|
||||
<strong>FDM (Gibbs):</strong> extended finite-difference solution of
|
||||
the damped wave equation.
|
||||
<br />
|
||||
<strong>FEA:</strong> dynamic beam-element FEM (Newmark-β) — more
|
||||
accurate for highly deviated wells.
|
||||
</p>
|
||||
<RadioGroup
|
||||
name="solverModel"
|
||||
value={runSettings.solverModel}
|
||||
onChange={(v) => onRunSettingsChange({ ...runSettings, solverModel: v })}
|
||||
options={[
|
||||
{ value: "fdm", label: "FDM (fast)" },
|
||||
{ value: "fea", label: "FEA (rigorous)" },
|
||||
{ value: "both", label: "Run both + compare" }
|
||||
]}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Workflow">
|
||||
<RadioGroup
|
||||
name="workflow"
|
||||
value={runSettings.workflow}
|
||||
onChange={(v) => onRunSettingsChange({ ...runSettings, workflow: v })}
|
||||
options={[
|
||||
{ value: "predictive", label: "Predictive (synthesize surface motion)" },
|
||||
{
|
||||
value: "diagnostic",
|
||||
label: "Diagnostic (uses measured surface card from Kinematics tab)"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Damping">
|
||||
<Row label="Up-stroke damping">
|
||||
<NumberField
|
||||
value={state.upStrokeDamping}
|
||||
step={0.01}
|
||||
onChange={(v) => update("upStrokeDamping", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Down-stroke damping">
|
||||
<NumberField
|
||||
value={state.downStrokeDamping}
|
||||
step={0.01}
|
||||
onChange={(v) => update("downStrokeDamping", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Non-dim. fluid damping">
|
||||
<NumberField
|
||||
value={state.nonDimensionalFluidDamping}
|
||||
step={0.1}
|
||||
onChange={(v) => update("nonDimensionalFluidDamping", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Friction Coefficients">
|
||||
<Row label="Rod friction coeff.">
|
||||
<NumberField
|
||||
value={state.rodFrictionCoefficient}
|
||||
step={0.01}
|
||||
onChange={(v) => update("rodFrictionCoefficient", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Stuffing-box friction">
|
||||
<NumberField
|
||||
value={state.stuffingBoxFriction}
|
||||
step={1}
|
||||
onChange={(v) => update("stuffingBoxFriction", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Molded guide ratio">
|
||||
<NumberField
|
||||
value={state.moldedGuideFrictionRatio}
|
||||
step={0.1}
|
||||
onChange={(v) => update("moldedGuideFrictionRatio", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Wheeled guide ratio">
|
||||
<NumberField
|
||||
value={state.wheeledGuideFrictionRatio}
|
||||
step={0.1}
|
||||
onChange={(v) => update("wheeledGuideFrictionRatio", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Other guide ratio">
|
||||
<NumberField
|
||||
value={state.otherGuideFrictionRatio}
|
||||
step={0.1}
|
||||
onChange={(v) => update("otherGuideFrictionRatio", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<button type="button" className="btn btn-secondary" onClick={onExportXml}>
|
||||
Export XML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || checks.hasBlockingError}
|
||||
onClick={onRun}
|
||||
>
|
||||
{loading ? "Solving…" : checks.hasBlockingError ? "Fix checks to run" : "▶ Run Solver"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
gui-ts/src/ui/tabs/TrajectoryTab.tsx
Normal file
126
gui-ts/src/ui/tabs/TrajectoryTab.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function TrajectoryTab({ store }: Props) {
|
||||
const { state, addSurveyRow, removeSurveyRow, updateSurveyRow, setSurvey } = store;
|
||||
|
||||
function loadVertical() {
|
||||
const depth = state.pumpDepth || 1727;
|
||||
setSurvey([
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: depth, inc: 0, azi: 0 }
|
||||
]);
|
||||
}
|
||||
|
||||
function loadDeviatedExample() {
|
||||
const depth = state.pumpDepth || 1727;
|
||||
setSurvey([
|
||||
{ md: 0, inc: 0, azi: 0 },
|
||||
{ md: Math.min(300, depth * 0.17), inc: 0, azi: 0 },
|
||||
{ md: Math.min(600, depth * 0.35), inc: 0, azi: 0 },
|
||||
{ md: Math.min(800, depth * 0.46), inc: 12, azi: 45 },
|
||||
{ md: Math.min(1000, depth * 0.58), inc: 25, azi: 45 },
|
||||
{ md: Math.min(1200, depth * 0.7), inc: 35, azi: 45 },
|
||||
{ md: Math.min(1500, depth * 0.87), inc: 35, azi: 45 },
|
||||
{ md: depth, inc: 35, azi: 45 }
|
||||
]);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setSurvey([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fieldset legend="Well Trajectory — Survey Table (MD / Inc / Az)">
|
||||
<p className="panel-note">
|
||||
Enter survey stations from surface to TD. Minimum curvature method (API
|
||||
Bulletin D20) is applied in the solver. First row should be 0 MD, 0 Inc, 0 Az.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button type="button" className="btn" onClick={() => addSurveyRow()}>
|
||||
Add Station
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={clearAll}>
|
||||
Clear All
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={loadVertical}>
|
||||
Load Vertical Default
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={loadDeviatedExample}>
|
||||
Load Deviated Example
|
||||
</button>
|
||||
<span className="panel-note" style={{ marginLeft: "auto" }}>
|
||||
{state.survey.length} station{state.survey.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="table-scroll">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>#</th>
|
||||
<th>MD</th>
|
||||
<th>Inclination (°)</th>
|
||||
<th>Azimuth (°)</th>
|
||||
<th style={{ width: 60 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.survey.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.md}
|
||||
onChange={(v) => updateSurveyRow(i, { md: v })}
|
||||
ariaLabel={`MD ${i + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.inc}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={180}
|
||||
onChange={(v) => updateSurveyRow(i, { inc: v })}
|
||||
ariaLabel={`Inc ${i + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberField
|
||||
value={row.azi}
|
||||
step={1}
|
||||
min={0}
|
||||
max={360}
|
||||
onChange={(v) => updateSurveyRow(i, { azi: v })}
|
||||
ariaLabel={`Azi ${i + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => removeSurveyRow(i)}
|
||||
aria-label={`Remove station ${i + 1}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{state.survey.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="empty-row">
|
||||
No survey stations. Add rows or load a preset.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
81
gui-ts/src/ui/tabs/WellTab.tsx
Normal file
81
gui-ts/src/ui/tabs/WellTab.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { CaseStore } from "../../state/useCaseStore";
|
||||
import { Fieldset } from "../common/Fieldset";
|
||||
import { Row } from "../common/Row";
|
||||
import { NumberField } from "../common/NumberField";
|
||||
import { TextField } from "../common/TextField";
|
||||
import { SelectField } from "../common/SelectField";
|
||||
|
||||
type Props = { store: CaseStore };
|
||||
|
||||
export function WellTab({ store }: Props) {
|
||||
const { state, update } = store;
|
||||
return (
|
||||
<div className="tab-grid two">
|
||||
<Fieldset legend="Well Identification">
|
||||
<Row label="Well Name / UWI" htmlFor="wellName">
|
||||
<TextField
|
||||
id="wellName"
|
||||
value={state.wellName}
|
||||
onChange={(v) => update("wellName", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Company" htmlFor="company">
|
||||
<TextField
|
||||
id="company"
|
||||
value={state.company}
|
||||
onChange={(v) => update("company", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Units Selection"
|
||||
htmlFor="unitsSelection"
|
||||
hint="0 or 2 = imperial oilfield; other values treated as SI"
|
||||
>
|
||||
<SelectField
|
||||
id="unitsSelection"
|
||||
value={state.unitsSelection}
|
||||
onChange={(v) => update("unitsSelection", v)}
|
||||
options={[
|
||||
{ value: 0, label: "0 — legacy imperial (default)" },
|
||||
{ value: 1, label: "1 — SI" },
|
||||
{ value: 2, label: "2 — imperial oilfield" }
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Depths / Tubing">
|
||||
<Row
|
||||
label="Pump Depth"
|
||||
htmlFor="pumpDepth"
|
||||
hint={state.unitsSelection === 1 ? "metres" : "feet (imperial)"}
|
||||
>
|
||||
<NumberField
|
||||
id="pumpDepth"
|
||||
value={state.pumpDepth}
|
||||
onChange={(v) => update("pumpDepth", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Tubing Anchor Location"
|
||||
htmlFor="tubingAnchor"
|
||||
hint={state.unitsSelection === 1 ? "metres" : "feet (imperial)"}
|
||||
>
|
||||
<NumberField
|
||||
id="tubingAnchor"
|
||||
value={state.tubingAnchorLocation}
|
||||
onChange={(v) => update("tubingAnchorLocation", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Tubing Nominal Size (in)" htmlFor="tubingSize">
|
||||
<NumberField
|
||||
id="tubingSize"
|
||||
value={state.tubingSize}
|
||||
step={0.125}
|
||||
onChange={(v) => update("tubingSize", v)}
|
||||
/>
|
||||
</Row>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
gui-ts/src/ui/tabs/rawFieldHelpers.ts
Normal file
21
gui-ts/src/ui/tabs/rawFieldHelpers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { RawFieldValue } from "../../state/caseModel";
|
||||
|
||||
export function textOf(value: RawFieldValue): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj._ === "string") return obj._;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function describeRawField(value: RawFieldValue): string {
|
||||
if (value === undefined || value === null) return "(empty)";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
15
gui-ts/tsconfig.json
Normal file
15
gui-ts/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
14
gui-ts/vite.config.ts
Normal file
14
gui-ts/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/testSetup.ts"]
|
||||
}
|
||||
});
|
||||
19
references/papers/README.md
Normal file
19
references/papers/README.md
Normal 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
14
solver-api/Dockerfile
Normal 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
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
19
solver-api/package.json
Normal 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
365
solver-api/src/app.js
Normal 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
68
solver-api/src/cardQa.js
Normal 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
50
solver-api/src/schema.js
Normal 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
8
solver-api/src/server.js
Normal 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}`);
|
||||
});
|
||||
303
solver-api/src/solverClient.js
Normal file
303
solver-api/src/solverClient.js
Normal 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
186
solver-api/src/xmlParser.js
Normal 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
|
||||
};
|
||||
}
|
||||
137
solver-api/tests/api.test.js
Normal file
137
solver-api/tests/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
9
solver-api/vitest.config.mjs
Normal file
9
solver-api/vitest.config.mjs
Normal 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
28
solver-c/CMakeLists.txt
Normal 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
131
solver-c/include/solver.h
Normal 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
|
||||
19
solver-c/include/solver_internal.h
Normal file
19
solver-c/include/solver_internal.h
Normal 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
360
solver-c/src/json_stdin.c
Normal 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
177
solver-c/src/main.c
Normal 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
170
solver-c/src/main_fea.c
Normal 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
207
solver-c/src/solver.c
Normal 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);
|
||||
}
|
||||
117
solver-c/src/solver_common.c
Normal file
117
solver-c/src/solver_common.c
Normal 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;
|
||||
}
|
||||
239
solver-c/src/solver_diagnostic.c
Normal file
239
solver-c/src/solver_diagnostic.c
Normal 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
432
solver-c/src/solver_fea.c
Normal 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;
|
||||
}
|
||||
57
solver-c/src/solver_fourier.c
Normal file
57
solver-c/src/solver_fourier.c
Normal 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
114
solver-c/src/trajectory.c
Normal 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;
|
||||
}
|
||||
252
solver-c/tests/test_solver.c
Normal file
252
solver-c/tests/test_solver.c
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user