Ubuntu Claude Opus 4.7 commited on
Commit ·
5ec1901
1
Parent(s): 2236dc1
Port GeoForce v1.1 CNN surrogate; smoke test green
Browse filesAdds surrogate/ with the ReservoirCNN architecture, frozen v1.1
normalization (6-channel input, 10-channel output), and a cached
predict() wrapper. Weights (248 KB, 57,802 params) copied from
ForceX-AI model registry and verified via tests/test_surrogate_smoke.py
(5/5 pass, <2s total).
Corrects surrogate-operator.md: output P range is [1e5, 5e7] Pa
(not [5e6, 25e6]); channel 0 is the full initial-T field, not a
scalar base_T. pyproject.toml pins Python 3.11+, CPU torch, iapws,
scipy, and dev/agent/app optional-deps groups.
Day 1 Morning milestone: done.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- .claude/agents/surrogate-operator.md +39 -21
- PROGRESS.md +9 -8
- pyproject.toml +55 -0
- surrogate/__init__.py +20 -0
- surrogate/encoding.py +132 -0
- surrogate/model.py +108 -0
- surrogate/predict.py +100 -0
- surrogate/weights/geoforce_cnn_v1.1.pt +3 -0
- tests/__init__.py +0 -0
- tests/test_surrogate_smoke.py +86 -0
.claude/agents/surrogate-operator.md
CHANGED
|
@@ -14,54 +14,72 @@ You are the interface to the deployed v1.1 CNN surrogate. You load the weights o
|
|
| 14 |
1. **Do not modify the weights file.** Treat `surrogate/weights/geoforce_cnn_v1.1.pt` as immutable.
|
| 15 |
2. **Do not change the 6-channel input encoding.** Normalization constants are frozen from v1.1 training. Any change invalidates the model.
|
| 16 |
3. **Do not retrain.** If accuracy is insufficient, report it; don't "fix" by retraining.
|
| 17 |
-
4. **Output shape is always `(10, 32, 32)`** — channels 0–4 are temperature at
|
| 18 |
|
| 19 |
## Frozen Normalization Constants (must match v1.1 training exactly)
|
| 20 |
|
|
|
|
|
|
|
| 21 |
```python
|
|
|
|
| 22 |
T_MIN, T_MAX = 25.0, 350.0 # Celsius
|
| 23 |
-
P_MIN, P_MAX =
|
|
|
|
|
|
|
| 24 |
LOG_PERM_MIN, LOG_PERM_MAX = -16.0, -12.0
|
| 25 |
-
|
| 26 |
-
DEPTH_MIN, DEPTH_MAX
|
|
|
|
|
|
|
|
|
|
| 27 |
```
|
| 28 |
|
| 29 |
## Input Channels (6, in order)
|
| 30 |
|
| 31 |
| Ch | Content | Normalization |
|
| 32 |
|---|---|---|
|
| 33 |
-
| 0 | Initial temperature field | `(T - T_MIN) / (T_MAX - T_MIN)` |
|
| 34 |
-
| 1 | Log₁₀ permeability | `(log_k - LOG_PERM_MIN) / (LOG_PERM_MAX - LOG_PERM_MIN)` |
|
| 35 |
-
| 2 | Well mask |
|
| 36 |
-
| 3 | Base pressure (scalar broadcast) | `(P -
|
| 37 |
-
| 4 | Porosity (scalar broadcast) | `(φ -
|
| 38 |
-
| 5 | Depth (scalar broadcast) | `(z - DEPTH_MIN) / (DEPTH_MAX - DEPTH_MIN)` |
|
|
|
|
|
|
|
| 39 |
|
| 40 |
## Output De-normalization
|
| 41 |
|
| 42 |
-
Channels 0–4: `T_out_C = ch * (T_MAX - T_MIN) + T_MIN`
|
| 43 |
-
Channels 5–9: `P_out_Pa = ch * (P_MAX - P_MIN) + P_MIN`
|
| 44 |
|
| 45 |
## Runtime
|
| 46 |
|
| 47 |
-
Called
|
| 48 |
|
| 49 |
```python
|
| 50 |
-
from surrogate
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
#
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
```
|
| 56 |
|
| 57 |
-
|
|
|
|
| 58 |
|
| 59 |
## Sanity Checks (run on every output)
|
| 60 |
|
| 61 |
- `T_out` in `[T_MIN - 2, T_MAX + 2]` (small tolerance for CNN drift)
|
| 62 |
-
- `P_out` in `[P_MIN - 1e5, P_MAX + 1e5]`
|
| 63 |
- No NaN / Inf
|
| 64 |
-
- Shape exactly `(
|
| 65 |
|
| 66 |
If any check fails, return an error to planner — **do not silently clamp**.
|
| 67 |
|
|
|
|
| 14 |
1. **Do not modify the weights file.** Treat `surrogate/weights/geoforce_cnn_v1.1.pt` as immutable.
|
| 15 |
2. **Do not change the 6-channel input encoding.** Normalization constants are frozen from v1.1 training. Any change invalidates the model.
|
| 16 |
3. **Do not retrain.** If accuracy is insufficient, report it; don't "fix" by retraining.
|
| 17 |
+
4. **Output shape is always `(10, 32, 32)`** — channels 0–4 are temperature at 5 timesteps, channels 5–9 are pressure at the same 5 timesteps.
|
| 18 |
|
| 19 |
## Frozen Normalization Constants (must match v1.1 training exactly)
|
| 20 |
|
| 21 |
+
Source of truth: `surrogate/encoding.py` `NORMALIZATION` dict, also embedded in the checkpoint.
|
| 22 |
+
|
| 23 |
```python
|
| 24 |
+
# OUTPUT ranges (used to de-normalize both predictions AND input channel 0)
|
| 25 |
T_MIN, T_MAX = 25.0, 350.0 # Celsius
|
| 26 |
+
P_MIN, P_MAX = 1.0e5, 5.0e7 # Pascal (0.1 to 50 MPa)
|
| 27 |
+
|
| 28 |
+
# INPUT channel ranges (only used to normalize)
|
| 29 |
LOG_PERM_MIN, LOG_PERM_MAX = -16.0, -12.0
|
| 30 |
+
POR_MIN, POR_MAX = 0.01, 0.15
|
| 31 |
+
DEPTH_MIN, DEPTH_MAX = 800.0, 2500.0 # meters
|
| 32 |
+
BASE_P_MIN, BASE_P_MAX = 5.0e6, 2.5e7 # Pascal (5 to 25 MPa)
|
| 33 |
+
# (BASE_T_MIN/MAX = 180/320 exist in the checkpoint but v1.1 does not use them —
|
| 34 |
+
# v1.1 consumes the full initial temperature FIELD as channel 0, not a scalar.)
|
| 35 |
```
|
| 36 |
|
| 37 |
## Input Channels (6, in order)
|
| 38 |
|
| 39 |
| Ch | Content | Normalization |
|
| 40 |
|---|---|---|
|
| 41 |
+
| 0 | **Initial temperature field** (full 32×32, degrees C) | `(T - T_MIN) / (T_MAX - T_MIN)` |
|
| 42 |
+
| 1 | Log₁₀ permeability (scalar, broadcast) | `(log_k - LOG_PERM_MIN) / (LOG_PERM_MAX - LOG_PERM_MIN)` |
|
| 43 |
+
| 2 | Well mask | 1.0 at well cell; neighbors decay as `1/(1 + manhattan_distance)` |
|
| 44 |
+
| 3 | Base pressure (scalar, broadcast) | `(P - BASE_P_MIN) / (BASE_P_MAX - BASE_P_MIN)` |
|
| 45 |
+
| 4 | Porosity (scalar, broadcast) | `(φ - POR_MIN) / (POR_MAX - POR_MIN)` |
|
| 46 |
+
| 5 | Depth (scalar, broadcast) | `(z - DEPTH_MIN) / (DEPTH_MAX - DEPTH_MIN)` |
|
| 47 |
+
|
| 48 |
+
All channels clipped to `[0, 1]` after normalization.
|
| 49 |
|
| 50 |
## Output De-normalization
|
| 51 |
|
| 52 |
+
- Channels 0–4: `T_out_C = ch * (T_MAX - T_MIN) + T_MIN`
|
| 53 |
+
- Channels 5–9: `P_out_Pa = ch * (P_MAX - P_MIN) + P_MIN`
|
| 54 |
|
| 55 |
## Runtime
|
| 56 |
|
| 57 |
+
Called via:
|
| 58 |
|
| 59 |
```python
|
| 60 |
+
from surrogate import predict
|
| 61 |
+
|
| 62 |
+
out = predict(
|
| 63 |
+
initial_temperature=T0, # shape (32, 32), degrees C
|
| 64 |
+
log_permeability=-13.5,
|
| 65 |
+
well_locations=[(16, 16)],
|
| 66 |
+
base_pressure=1.5e7,
|
| 67 |
+
porosity=0.08,
|
| 68 |
+
depth=1800.0,
|
| 69 |
+
)
|
| 70 |
+
# out["temperature"] shape (5, 32, 32) °C
|
| 71 |
+
# out["pressure"] shape (5, 32, 32) Pa
|
| 72 |
```
|
| 73 |
|
| 74 |
+
Model weights are cached after first `predict()` / `load_model()` call.
|
| 75 |
+
Target inference: < 5 ms/call on CPU (the smoke test budget is 50 ms to stay slack-tolerant).
|
| 76 |
|
| 77 |
## Sanity Checks (run on every output)
|
| 78 |
|
| 79 |
- `T_out` in `[T_MIN - 2, T_MAX + 2]` (small tolerance for CNN drift)
|
| 80 |
+
- `P_out` in `[P_MIN - 1e5, P_MAX + 1e5]` — i.e., roughly `[0, 50.1 MPa]`
|
| 81 |
- No NaN / Inf
|
| 82 |
+
- Shape exactly `(5, 32, 32)` per field
|
| 83 |
|
| 84 |
If any check fails, return an error to planner — **do not silently clamp**.
|
| 85 |
|
PROGRESS.md
CHANGED
|
@@ -18,17 +18,18 @@ Live task tracking. Updated at the end of every work block. Source of truth = th
|
|
| 18 |
- [x] `.claude/` skills defined (6)
|
| 19 |
- [x] `.claude/` commands defined (4)
|
| 20 |
- [x] `.claude/settings.json` — model pinned to `claude-opus-4-7`
|
| 21 |
-
- [
|
| 22 |
-
- [
|
| 23 |
-
- [
|
|
|
|
| 24 |
|
| 25 |
## Day 1 — Build both engines
|
| 26 |
|
| 27 |
-
### Morning (3h)
|
| 28 |
-
- [
|
| 29 |
-
- [
|
| 30 |
-
- [
|
| 31 |
-
- [
|
| 32 |
|
| 33 |
### Afternoon (4h) — Agent team builds GeoForce-Solver
|
| 34 |
- [ ] `solver/properties.py` — IAPWS-IF97 wrappers
|
|
|
|
| 18 |
- [x] `.claude/` skills defined (6)
|
| 19 |
- [x] `.claude/` commands defined (4)
|
| 20 |
- [x] `.claude/settings.json` — model pinned to `claude-opus-4-7`
|
| 21 |
+
- [x] Fresh `.venv` + `pyproject.toml`
|
| 22 |
+
- [x] `ANTHROPIC_API_KEY` in `.env` (gitignored) + round-trip tested
|
| 23 |
+
- [x] `.gitignore` protects secrets
|
| 24 |
+
- [~] Registration + Discord acceptance email — deferred per user (2026-04-23)
|
| 25 |
|
| 26 |
## Day 1 — Build both engines
|
| 27 |
|
| 28 |
+
### Morning (3h) — COMPLETE 2026-04-23
|
| 29 |
+
- [x] Copy `geoforce_cnn_v1.1.pt` to `surrogate/weights/` (248,595 bytes, from ForceX-AI/products/model_registry)
|
| 30 |
+
- [x] Port `ReservoirCNN` class + encoding to `surrogate/` (model.py, encoding.py, predict.py)
|
| 31 |
+
- [x] `tests/test_surrogate_smoke.py` green — 5/5 pass, 1.7s total
|
| 32 |
+
- [x] Claude scaffolding discoverable (subagents + skills load; `surrogate-operator.md` corrected to match real v1.1 encoding)
|
| 33 |
|
| 34 |
### Afternoon (4h) — Agent team builds GeoForce-Solver
|
| 35 |
- [ ] `solver/properties.py` — IAPWS-IF97 wrappers
|
pyproject.toml
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "geoforce-cchackathon"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Opus 4.7 agents orchestrate GeoForce-Solver + v1.1 CNN surrogate for Indonesian geothermal engineering questions"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.11"
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
authors = [{ name = "Robi Dany Riupassa" }]
|
| 13 |
+
|
| 14 |
+
dependencies = [
|
| 15 |
+
"numpy>=1.26",
|
| 16 |
+
"scipy>=1.12",
|
| 17 |
+
"torch>=2.2",
|
| 18 |
+
"iapws>=1.5.3",
|
| 19 |
+
"matplotlib>=3.8",
|
| 20 |
+
"pyyaml>=6.0",
|
| 21 |
+
"python-dotenv>=1.0",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[project.optional-dependencies]
|
| 25 |
+
agent = [
|
| 26 |
+
"claude-agent-sdk>=0.1.0",
|
| 27 |
+
"anthropic>=0.40.0",
|
| 28 |
+
"fastapi>=0.110",
|
| 29 |
+
"uvicorn[standard]>=0.29",
|
| 30 |
+
"sse-starlette>=2.0",
|
| 31 |
+
]
|
| 32 |
+
app = [
|
| 33 |
+
"streamlit>=1.33",
|
| 34 |
+
]
|
| 35 |
+
dev = [
|
| 36 |
+
"pytest>=8.0",
|
| 37 |
+
"pytest-cov>=4.1",
|
| 38 |
+
"ruff>=0.3",
|
| 39 |
+
"mypy>=1.9",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
[tool.setuptools.packages.find]
|
| 43 |
+
include = ["surrogate*", "solver*", "agent*", "tools*"]
|
| 44 |
+
|
| 45 |
+
[tool.pytest.ini_options]
|
| 46 |
+
testpaths = ["tests"]
|
| 47 |
+
addopts = "-ra -q"
|
| 48 |
+
|
| 49 |
+
[tool.ruff]
|
| 50 |
+
line-length = 100
|
| 51 |
+
target-version = "py311"
|
| 52 |
+
|
| 53 |
+
[tool.ruff.lint]
|
| 54 |
+
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
|
| 55 |
+
ignore = ["E501"]
|
surrogate/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GeoForce v1.1 CNN surrogate — ported from ForceX-AI for GeoForce-CCHackathon."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from .encoding import (
|
| 6 |
+
NORMALIZATION,
|
| 7 |
+
build_input_tensor,
|
| 8 |
+
denormalize_output,
|
| 9 |
+
)
|
| 10 |
+
from .model import ReservoirCNN
|
| 11 |
+
from .predict import load_model, predict
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"NORMALIZATION",
|
| 15 |
+
"ReservoirCNN",
|
| 16 |
+
"build_input_tensor",
|
| 17 |
+
"denormalize_output",
|
| 18 |
+
"load_model",
|
| 19 |
+
"predict",
|
| 20 |
+
]
|
surrogate/encoding.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v1.1 input/output encoding for ReservoirCNN.
|
| 2 |
+
|
| 3 |
+
FROZEN — these constants must match the values baked into
|
| 4 |
+
`weights/geoforce_cnn_v1.1.pt` exactly. Any change invalidates the weights.
|
| 5 |
+
|
| 6 |
+
v1.1 input channels (6, 32, 32):
|
| 7 |
+
0: initial temperature field (per-cell, normalized to [0, 1] on [T_MIN, T_MAX])
|
| 8 |
+
1: log10 permeability (scalar broadcast, normalized on [LOG_PERM_MIN, LOG_PERM_MAX])
|
| 9 |
+
2: well mask (1.0 at well cells, decaying to neighbors)
|
| 10 |
+
3: base pressure (scalar broadcast, normalized on [BASE_P_MIN, BASE_P_MAX])
|
| 11 |
+
4: porosity (scalar broadcast, normalized on [POR_MIN, POR_MAX])
|
| 12 |
+
5: depth (scalar broadcast, normalized on [DEPTH_MIN, DEPTH_MAX])
|
| 13 |
+
|
| 14 |
+
Output channels (10, 32, 32):
|
| 15 |
+
0-4: temperature at 5 timesteps, sigmoid-normalized on [T_MIN, T_MAX] in degrees C
|
| 16 |
+
5-9: pressure at 5 timesteps, sigmoid-normalized on [P_MIN, P_MAX] in Pa
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
|
| 24 |
+
GRID_SIZE = 32
|
| 25 |
+
N_TIME_STEPS = 5
|
| 26 |
+
|
| 27 |
+
# Frozen v1.1 normalization constants (embedded in the checkpoint).
|
| 28 |
+
NORMALIZATION: dict[str, float] = {
|
| 29 |
+
"T_MIN": 25.0,
|
| 30 |
+
"T_MAX": 350.0,
|
| 31 |
+
"P_MIN": 1.0e5,
|
| 32 |
+
"P_MAX": 5.0e7,
|
| 33 |
+
"LOG_PERM_MIN": -16.0,
|
| 34 |
+
"LOG_PERM_MAX": -12.0,
|
| 35 |
+
"POR_MIN": 0.01,
|
| 36 |
+
"POR_MAX": 0.15,
|
| 37 |
+
"DEPTH_MIN": 800.0,
|
| 38 |
+
"DEPTH_MAX": 2500.0,
|
| 39 |
+
"BASE_T_MIN": 180.0,
|
| 40 |
+
"BASE_T_MAX": 320.0,
|
| 41 |
+
"BASE_P_MIN": 5.0e6,
|
| 42 |
+
"BASE_P_MAX": 2.5e7,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _norm(value: float, lo: float, hi: float) -> float:
|
| 47 |
+
return float(np.clip((value - lo) / (hi - lo), 0.0, 1.0))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _build_well_mask(
|
| 51 |
+
well_locations: list[tuple[int, int]],
|
| 52 |
+
grid_size: int = GRID_SIZE,
|
| 53 |
+
) -> np.ndarray:
|
| 54 |
+
"""Match the training well-mask encoding exactly (1.0 at well, decay to neighbors)."""
|
| 55 |
+
mask = np.zeros((grid_size, grid_size), dtype=np.float32)
|
| 56 |
+
for wr, wc in well_locations:
|
| 57 |
+
wr, wc = int(wr), int(wc)
|
| 58 |
+
if not (0 <= wr < grid_size and 0 <= wc < grid_size):
|
| 59 |
+
continue
|
| 60 |
+
mask[wr, wc] = 1.0
|
| 61 |
+
for dr in range(-1, 2):
|
| 62 |
+
for dc in range(-1, 2):
|
| 63 |
+
r, c = wr + dr, wc + dc
|
| 64 |
+
if 0 <= r < grid_size and 0 <= c < grid_size:
|
| 65 |
+
dist = abs(dr) + abs(dc)
|
| 66 |
+
mask[r, c] = max(mask[r, c], 1.0 / (1.0 + dist))
|
| 67 |
+
return mask
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def build_input_tensor(
|
| 71 |
+
*,
|
| 72 |
+
initial_temperature: np.ndarray,
|
| 73 |
+
log_permeability: float,
|
| 74 |
+
well_locations: list[tuple[int, int]],
|
| 75 |
+
base_pressure: float,
|
| 76 |
+
porosity: float,
|
| 77 |
+
depth: float,
|
| 78 |
+
grid_size: int = GRID_SIZE,
|
| 79 |
+
) -> torch.Tensor:
|
| 80 |
+
"""Assemble the 6-channel input tensor matching v1.1 training.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
initial_temperature: 2D array of shape (32, 32) in degrees Celsius.
|
| 84 |
+
log_permeability: scalar log10 permeability (typical -16 to -12).
|
| 85 |
+
well_locations: list of (row, col) integer tuples.
|
| 86 |
+
base_pressure: scalar pressure at reservoir base (Pa).
|
| 87 |
+
porosity: dimensionless (0.01 to 0.15).
|
| 88 |
+
depth: reservoir depth (m, 800 to 2500).
|
| 89 |
+
grid_size: grid cells per side (default 32, must match weights).
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
torch.Tensor of shape (1, 6, grid_size, grid_size), dtype float32.
|
| 93 |
+
"""
|
| 94 |
+
if initial_temperature.shape != (grid_size, grid_size):
|
| 95 |
+
msg = (
|
| 96 |
+
f"initial_temperature shape {initial_temperature.shape} "
|
| 97 |
+
f"does not match grid_size={grid_size}"
|
| 98 |
+
)
|
| 99 |
+
raise ValueError(msg)
|
| 100 |
+
|
| 101 |
+
n = NORMALIZATION
|
| 102 |
+
arr = np.zeros((6, grid_size, grid_size), dtype=np.float32)
|
| 103 |
+
|
| 104 |
+
t0_norm = (initial_temperature - n["T_MIN"]) / (n["T_MAX"] - n["T_MIN"])
|
| 105 |
+
arr[0] = np.clip(t0_norm, 0.0, 1.0).astype(np.float32)
|
| 106 |
+
|
| 107 |
+
arr[1] = _norm(log_permeability, n["LOG_PERM_MIN"], n["LOG_PERM_MAX"])
|
| 108 |
+
arr[2] = _build_well_mask(well_locations, grid_size)
|
| 109 |
+
arr[3] = _norm(base_pressure, n["BASE_P_MIN"], n["BASE_P_MAX"])
|
| 110 |
+
arr[4] = _norm(porosity, n["POR_MIN"], n["POR_MAX"])
|
| 111 |
+
arr[5] = _norm(depth, n["DEPTH_MIN"], n["DEPTH_MAX"])
|
| 112 |
+
|
| 113 |
+
return torch.from_numpy(arr).unsqueeze(0)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def denormalize_output(out: torch.Tensor) -> dict[str, np.ndarray]:
|
| 117 |
+
"""Convert model output from [0,1] to physical units.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
out: tensor of shape (batch, 10, H, W) with values in [0, 1].
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
dict with keys 'temperature' (shape (batch, 5, H, W), deg C) and
|
| 124 |
+
'pressure' (shape (batch, 5, H, W), Pa).
|
| 125 |
+
"""
|
| 126 |
+
n = NORMALIZATION
|
| 127 |
+
arr = out.detach().cpu().numpy()
|
| 128 |
+
t_norm = arr[:, :N_TIME_STEPS]
|
| 129 |
+
p_norm = arr[:, N_TIME_STEPS:]
|
| 130 |
+
temperature = t_norm * (n["T_MAX"] - n["T_MIN"]) + n["T_MIN"]
|
| 131 |
+
pressure = p_norm * (n["P_MAX"] - n["P_MIN"]) + n["P_MIN"]
|
| 132 |
+
return {"temperature": temperature, "pressure": pressure}
|
surrogate/model.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ReservoirCNN architecture for GeoForce v1.1.
|
| 2 |
+
|
| 3 |
+
Ported verbatim from ForceX-AI's training code. The state dict of
|
| 4 |
+
`weights/geoforce_cnn_v1.1.pt` loads into this class without renaming.
|
| 5 |
+
|
| 6 |
+
Architecture:
|
| 7 |
+
encoder: ConvBlock(6 -> 32) -> ConvBlock(32 -> 32)
|
| 8 |
+
middle: ResidualBlock(32) x 2
|
| 9 |
+
decoder: ConvBlock(32 -> 32) -> Conv2d(32 -> 10, 1x1) -> Sigmoid
|
| 10 |
+
|
| 11 |
+
57,802 parameters. ~3 ms per inference on CPU.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ConvBlock(nn.Module):
|
| 21 |
+
"""Conv2d -> BatchNorm -> ReLU."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, in_channels: int, out_channels: int, kernel_size: int = 3) -> None:
|
| 24 |
+
super().__init__()
|
| 25 |
+
self.conv = nn.Conv2d(
|
| 26 |
+
in_channels,
|
| 27 |
+
out_channels,
|
| 28 |
+
kernel_size,
|
| 29 |
+
padding=kernel_size // 2,
|
| 30 |
+
bias=False,
|
| 31 |
+
)
|
| 32 |
+
self.bn = nn.BatchNorm2d(out_channels)
|
| 33 |
+
self.relu = nn.ReLU(inplace=True)
|
| 34 |
+
|
| 35 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 36 |
+
return self.relu(self.bn(self.conv(x)))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ResidualBlock(nn.Module):
|
| 40 |
+
"""Residual block with two 3x3 convs and a skip connection."""
|
| 41 |
+
|
| 42 |
+
def __init__(self, channels: int) -> None:
|
| 43 |
+
super().__init__()
|
| 44 |
+
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
|
| 45 |
+
self.bn1 = nn.BatchNorm2d(channels)
|
| 46 |
+
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
|
| 47 |
+
self.bn2 = nn.BatchNorm2d(channels)
|
| 48 |
+
self.relu = nn.ReLU(inplace=True)
|
| 49 |
+
|
| 50 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 51 |
+
residual = x
|
| 52 |
+
out = self.relu(self.bn1(self.conv1(x)))
|
| 53 |
+
out = self.bn2(self.conv2(out))
|
| 54 |
+
out = out + residual
|
| 55 |
+
return self.relu(out)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ReservoirCNN(nn.Module):
|
| 59 |
+
"""CNN surrogate for 2D geothermal reservoir T and P prediction.
|
| 60 |
+
|
| 61 |
+
Input shape: (batch, 6, 32, 32) — see `encoding.py` for channel layout.
|
| 62 |
+
Output shape: (batch, 10, 32, 32) — 5 T timesteps then 5 P timesteps,
|
| 63 |
+
each normalized to [0, 1] via a final sigmoid.
|
| 64 |
+
|
| 65 |
+
Use `predict.predict()` for the normal inference path (loads weights,
|
| 66 |
+
builds tensor, denormalizes to physical units).
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
in_channels: int = 6,
|
| 72 |
+
n_time_steps: int = 5,
|
| 73 |
+
base_filters: int = 32,
|
| 74 |
+
) -> None:
|
| 75 |
+
super().__init__()
|
| 76 |
+
self.in_channels = in_channels
|
| 77 |
+
self.n_time_steps = n_time_steps
|
| 78 |
+
self.out_channels = 2 * n_time_steps
|
| 79 |
+
|
| 80 |
+
self.encoder = nn.Sequential(
|
| 81 |
+
ConvBlock(in_channels, base_filters, 3),
|
| 82 |
+
ConvBlock(base_filters, base_filters, 3),
|
| 83 |
+
)
|
| 84 |
+
self.middle = nn.Sequential(
|
| 85 |
+
ResidualBlock(base_filters),
|
| 86 |
+
ResidualBlock(base_filters),
|
| 87 |
+
)
|
| 88 |
+
self.decoder = nn.Sequential(
|
| 89 |
+
ConvBlock(base_filters, base_filters, 3),
|
| 90 |
+
nn.Conv2d(base_filters, self.out_channels, 1),
|
| 91 |
+
nn.Sigmoid(),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
self._init_weights()
|
| 95 |
+
|
| 96 |
+
def _init_weights(self) -> None:
|
| 97 |
+
for m in self.modules():
|
| 98 |
+
if isinstance(m, nn.Conv2d):
|
| 99 |
+
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
|
| 100 |
+
elif isinstance(m, nn.BatchNorm2d):
|
| 101 |
+
nn.init.constant_(m.weight, 1)
|
| 102 |
+
nn.init.constant_(m.bias, 0)
|
| 103 |
+
|
| 104 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 105 |
+
return self.decoder(self.middle(self.encoder(x)))
|
| 106 |
+
|
| 107 |
+
def count_parameters(self) -> int:
|
| 108 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
surrogate/predict.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model loader and inference wrapper for GeoForce v1.1 surrogate."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
from .encoding import build_input_tensor, denormalize_output
|
| 12 |
+
from .model import ReservoirCNN
|
| 13 |
+
|
| 14 |
+
DEFAULT_WEIGHTS = Path(__file__).parent / "weights" / "geoforce_cnn_v1.1.pt"
|
| 15 |
+
|
| 16 |
+
_model_cache: ReservoirCNN | None = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_model(
|
| 20 |
+
weights_path: str | Path | None = None,
|
| 21 |
+
device: str | torch.device = "cpu",
|
| 22 |
+
) -> ReservoirCNN:
|
| 23 |
+
"""Load v1.1 ReservoirCNN weights, cached in process memory.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
weights_path: path to the .pt checkpoint. Defaults to the bundled v1.1.
|
| 27 |
+
device: torch device (default CPU — the model is tiny).
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
ReservoirCNN in eval mode with weights loaded.
|
| 31 |
+
"""
|
| 32 |
+
global _model_cache
|
| 33 |
+
if _model_cache is not None and weights_path is None:
|
| 34 |
+
return _model_cache
|
| 35 |
+
|
| 36 |
+
path = Path(weights_path) if weights_path else DEFAULT_WEIGHTS
|
| 37 |
+
if not path.exists():
|
| 38 |
+
msg = f"weights not found at {path}"
|
| 39 |
+
raise FileNotFoundError(msg)
|
| 40 |
+
|
| 41 |
+
ckpt: Any = torch.load(path, map_location=device, weights_only=False)
|
| 42 |
+
state_dict = ckpt["model_state_dict"] if isinstance(ckpt, dict) and "model_state_dict" in ckpt else ckpt
|
| 43 |
+
in_channels = ckpt.get("in_channels", 6) if isinstance(ckpt, dict) else 6
|
| 44 |
+
|
| 45 |
+
model = ReservoirCNN(in_channels=in_channels)
|
| 46 |
+
model.load_state_dict(state_dict)
|
| 47 |
+
model.eval()
|
| 48 |
+
model.to(device)
|
| 49 |
+
|
| 50 |
+
if weights_path is None:
|
| 51 |
+
_model_cache = model
|
| 52 |
+
return model
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def predict(
|
| 56 |
+
*,
|
| 57 |
+
initial_temperature: np.ndarray,
|
| 58 |
+
log_permeability: float,
|
| 59 |
+
well_locations: list[tuple[int, int]],
|
| 60 |
+
base_pressure: float,
|
| 61 |
+
porosity: float,
|
| 62 |
+
depth: float,
|
| 63 |
+
model: ReservoirCNN | None = None,
|
| 64 |
+
) -> dict[str, np.ndarray]:
|
| 65 |
+
"""Run a single v1.1 surrogate prediction.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
initial_temperature: (32, 32) array in deg C.
|
| 69 |
+
log_permeability: scalar log10 permeability.
|
| 70 |
+
well_locations: list of (row, col) tuples.
|
| 71 |
+
base_pressure: Pa.
|
| 72 |
+
porosity: dimensionless.
|
| 73 |
+
depth: m.
|
| 74 |
+
model: pre-loaded ReservoirCNN, or None to use the cached default.
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
dict with:
|
| 78 |
+
'temperature': (5, 32, 32) deg C
|
| 79 |
+
'pressure': (5, 32, 32) Pa
|
| 80 |
+
"""
|
| 81 |
+
if model is None:
|
| 82 |
+
model = load_model()
|
| 83 |
+
|
| 84 |
+
x = build_input_tensor(
|
| 85 |
+
initial_temperature=initial_temperature,
|
| 86 |
+
log_permeability=log_permeability,
|
| 87 |
+
well_locations=well_locations,
|
| 88 |
+
base_pressure=base_pressure,
|
| 89 |
+
porosity=porosity,
|
| 90 |
+
depth=depth,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
with torch.no_grad():
|
| 94 |
+
y = model(x)
|
| 95 |
+
|
| 96 |
+
out = denormalize_output(y)
|
| 97 |
+
return {
|
| 98 |
+
"temperature": out["temperature"][0],
|
| 99 |
+
"pressure": out["pressure"][0],
|
| 100 |
+
}
|
surrogate/weights/geoforce_cnn_v1.1.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3701d0ea2bcb4d8a0d68eb8e8ed1d79b3bb010a8396227e4dbddbc4327746386
|
| 3 |
+
size 248595
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_surrogate_smoke.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke test for GeoForce v1.1 surrogate port.
|
| 2 |
+
|
| 3 |
+
Asserts:
|
| 4 |
+
1. Weights load without error.
|
| 5 |
+
2. Parameter count = 57,802 (matches v1.1 metadata).
|
| 6 |
+
3. Inference on a canonical Ulubelu-like scenario runs.
|
| 7 |
+
4. Output shape is (5, 32, 32) per field.
|
| 8 |
+
5. Temperature stays in [25, 350] C and pressure in [1e5, 5e7] Pa.
|
| 9 |
+
6. Inference completes in < 50 ms on CPU (generous bound vs. 3.2 ms target).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pytest
|
| 18 |
+
|
| 19 |
+
from surrogate import load_model, predict
|
| 20 |
+
from surrogate.encoding import GRID_SIZE, NORMALIZATION
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture(scope="module")
|
| 24 |
+
def model():
|
| 25 |
+
return load_model()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _ulubelu_like_scenario() -> dict:
|
| 29 |
+
"""Canonical single-producer liquid-dominated scenario."""
|
| 30 |
+
depth = 1800.0
|
| 31 |
+
base_temperature_c = 250.0
|
| 32 |
+
# Hydrostatic-ish gradient for initial temperature: warm at base, cooler up.
|
| 33 |
+
z_grad = np.linspace(base_temperature_c, base_temperature_c - 40.0, GRID_SIZE)
|
| 34 |
+
initial_temperature = np.broadcast_to(
|
| 35 |
+
z_grad.reshape(-1, 1), (GRID_SIZE, GRID_SIZE)
|
| 36 |
+
).astype(np.float32).copy()
|
| 37 |
+
return {
|
| 38 |
+
"initial_temperature": initial_temperature,
|
| 39 |
+
"log_permeability": -13.5,
|
| 40 |
+
"well_locations": [(16, 16)],
|
| 41 |
+
"base_pressure": 1.5e7, # 15 MPa
|
| 42 |
+
"porosity": 0.08,
|
| 43 |
+
"depth": depth,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_model_parameter_count(model):
|
| 48 |
+
assert model.count_parameters() == 57_802
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_model_input_channels(model):
|
| 52 |
+
assert model.in_channels == 6
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_prediction_shape_and_range(model):
|
| 56 |
+
scenario = _ulubelu_like_scenario()
|
| 57 |
+
result = predict(**scenario, model=model)
|
| 58 |
+
|
| 59 |
+
assert set(result.keys()) == {"temperature", "pressure"}
|
| 60 |
+
assert result["temperature"].shape == (5, GRID_SIZE, GRID_SIZE)
|
| 61 |
+
assert result["pressure"].shape == (5, GRID_SIZE, GRID_SIZE)
|
| 62 |
+
|
| 63 |
+
t = result["temperature"]
|
| 64 |
+
p = result["pressure"]
|
| 65 |
+
assert NORMALIZATION["T_MIN"] - 0.1 <= t.min()
|
| 66 |
+
assert t.max() <= NORMALIZATION["T_MAX"] + 0.1
|
| 67 |
+
assert NORMALIZATION["P_MIN"] - 1.0 <= p.min()
|
| 68 |
+
assert p.max() <= NORMALIZATION["P_MAX"] + 1.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_inference_latency(model):
|
| 72 |
+
scenario = _ulubelu_like_scenario()
|
| 73 |
+
# Warm-up run first (first inference pays init cost).
|
| 74 |
+
predict(**scenario, model=model)
|
| 75 |
+
|
| 76 |
+
t0 = time.perf_counter()
|
| 77 |
+
for _ in range(5):
|
| 78 |
+
predict(**scenario, model=model)
|
| 79 |
+
elapsed_ms = (time.perf_counter() - t0) * 1000 / 5
|
| 80 |
+
assert elapsed_ms < 50, f"inference {elapsed_ms:.1f} ms exceeds 50 ms budget"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_cache_is_idempotent():
|
| 84 |
+
m1 = load_model()
|
| 85 |
+
m2 = load_model()
|
| 86 |
+
assert m1 is m2
|