| """Structured 2D grid for GeoForce-Solver. |
| |
| The grid is logically rectangular with uniform spacing in each direction. |
| Two conventional orientations are supported: |
| |
| * **Horizontal (x, y):** used by the Theis benchmark (no gravity). |
| * **Vertical (x, z):** used by real geothermal demos (gravity + density). |
| |
| The grid object itself is orientation-agnostic — it just provides cell |
| volumes, face areas, inter-cell distances, and center coordinates. |
| The PDE modules (darcy.py, energy.py) choose whether to enable gravity |
| based on the scenario dictionary, not on the grid type. |
| |
| For a 2D slab simulation, we fix the out-of-plane thickness to 1 m, so |
| "volumetric" and "per-unit-thickness" rates coincide. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
|
|
| import numpy as np |
|
|
| THICKNESS = 1.0 |
|
|
|
|
| @dataclass(frozen=True) |
| class Grid: |
| """Uniform structured 2D grid. |
| |
| Attributes: |
| nx: number of cells along the first axis. |
| ny: number of cells along the second axis (y or z depending on orientation). |
| dx: cell size along axis 0 (m). |
| dy: cell size along axis 1 (m). |
| """ |
|
|
| nx: int |
| ny: int |
| dx: float |
| dy: float |
|
|
| @property |
| def shape(self) -> tuple[int, int]: |
| return (self.nx, self.ny) |
|
|
| @property |
| def n_cells(self) -> int: |
| return self.nx * self.ny |
|
|
| @property |
| def cell_volume(self) -> float: |
| """Constant cell volume (m^3) assuming unit out-of-plane thickness.""" |
| return self.dx * self.dy * THICKNESS |
|
|
| @property |
| def face_area_x(self) -> float: |
| """Area of a face normal to axis 0 (m^2).""" |
| return self.dy * THICKNESS |
|
|
| @property |
| def face_area_y(self) -> float: |
| """Area of a face normal to axis 1 (m^2).""" |
| return self.dx * THICKNESS |
|
|
| def cell_centers(self) -> tuple[np.ndarray, np.ndarray]: |
| """Return (X, Y) coordinate arrays of shape (nx, ny) at cell centers.""" |
| x = (np.arange(self.nx) + 0.5) * self.dx |
| y = (np.arange(self.ny) + 0.5) * self.dy |
| X, Y = np.meshgrid(x, y, indexing="ij") |
| return X, Y |
|
|
| def flat_index(self, i: int, j: int) -> int: |
| """Row-major flattening: flat = i * ny + j.""" |
| return i * self.ny + j |
|
|
| def radial_distance_from(self, i0: int, j0: int) -> np.ndarray: |
| """Return an (nx, ny) array of distances from the center of cell (i0, j0).""" |
| X, Y = self.cell_centers() |
| x0 = (i0 + 0.5) * self.dx |
| y0 = (j0 + 0.5) * self.dy |
| return np.sqrt((X - x0) ** 2 + (Y - y0) ** 2) |
|
|