Ubuntu Claude Opus 4.7 commited on
Commit
5ec1901
·
1 Parent(s): 2236dc1

Port GeoForce v1.1 CNN surrogate; smoke test green

Browse files

Adds 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 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 years 4/8/12/16/20, channels 5–9 are pressure at the same times.
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 = 5e6, 25e6 # Pascal
 
 
24
  LOG_PERM_MIN, LOG_PERM_MAX = -16.0, -12.0
25
- POROSITY_MIN, POROSITY_MAX = 0.01, 0.15
26
- DEPTH_MIN, DEPTH_MAX = 800.0, 2500.0 # meters
 
 
 
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 | Gaussian decay around well cells |
36
- | 3 | Base pressure (scalar broadcast) | `(P - P_MIN) / (P_MAX - P_MIN)` |
37
- | 4 | Porosity (scalar broadcast) | `(φ - POROSITY_MIN) / (POROSITY_MAX - POROSITY_MIN)` |
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 from `tools/predict_surrogate.py`. Typical:
48
 
49
  ```python
50
- from surrogate.infer import predict
51
- out = predict(scenario_dict)
52
- # out["T"] shape (5, 32, 32) °C
53
- # out["P"] shape (5, 32, 32) Pa
54
- # out["t_ms"] inference time
 
 
 
 
 
 
 
55
  ```
56
 
57
- Target: < 5 ms per call on CPU. If exceeded, flag to planner.
 
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 `(10, 32, 32)`
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
- - [ ] Fresh `.venv` + `pyproject.toml`
22
- - [ ] `ANTHROPIC_API_KEY` verified in shell
23
- - [ ] Registration + Discord acceptance email found
 
24
 
25
  ## Day 1 — Build both engines
26
 
27
- ### Morning (3h)
28
- - [ ] Copy `geoforce_cnn_v1.1.pt` to `surrogate/weights/`
29
- - [ ] Port `ReservoirCNN` class + encoding to `surrogate/`
30
- - [ ] `tests/test_surrogate_smoke.py` green
31
- - [ ] Claude scaffolding discoverable (subagents load)
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