oreblocks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oreblocks/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """oreblocks — synthetic 3-D ore-body block models of the MineLib nature.
2
+
3
+ Seeded deposit archetypes with per-block grades, bench (level) structure, slope precedence, UPIT
4
+ economics with per-block optimal destination, an exact max-closure solver, extraction states with
5
+ loading faces, and MineLib ``.blocks/.prec/.upit`` read/write. Everything deterministic given a
6
+ seed; every generated instance is clearly labelled SYNTHETIC.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .economics import Econ, block_values, cutoff_grade, is_ore
12
+ from .extraction import ExtractionState, Face, extraction_state, loading_faces
13
+ from .fields import ARCHETYPES, Deposit, make_deposit
14
+ from .grid import BlockGrid
15
+ from .minelib_io import read_blocks, read_meta, read_prec, read_upit, write_minelib
16
+ from .precedence import Precedence, build_precedence, slope_offsets
17
+ from .twins import Twin, make_twin
18
+ from .upit import UpitResult, solve_upit
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "ARCHETYPES",
23
+ "BlockGrid",
24
+ "Deposit",
25
+ "Econ",
26
+ "ExtractionState",
27
+ "Face",
28
+ "Precedence",
29
+ "Twin",
30
+ "UpitResult",
31
+ "block_values",
32
+ "build_precedence",
33
+ "cutoff_grade",
34
+ "extraction_state",
35
+ "is_ore",
36
+ "loading_faces",
37
+ "make_deposit",
38
+ "make_twin",
39
+ "read_blocks",
40
+ "read_meta",
41
+ "read_prec",
42
+ "read_upit",
43
+ "slope_offsets",
44
+ "solve_upit",
45
+ "write_minelib",
46
+ ]
oreblocks/_rng.py ADDED
@@ -0,0 +1,20 @@
1
+ """Seeded, named random streams — same discipline as minehaulsim.
2
+
3
+ Every stochastic component draws from its own named stream derived from (seed, name), so adding a
4
+ new consumer never perturbs existing draws and results are byte-identical given the seed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+
11
+ import numpy as np
12
+
13
+ __all__ = ["stream"]
14
+
15
+
16
+ def stream(seed: int, name: str) -> np.random.Generator:
17
+ """A generator for the (seed, name) pair — stable across platforms and numpy versions."""
18
+ digest = hashlib.sha256(f"{seed}:{name}".encode()).digest()
19
+ words = [int.from_bytes(digest[k : k + 4], "little") for k in range(0, 16, 4)]
20
+ return np.random.Generator(np.random.PCG64(np.random.SeedSequence(words)))
oreblocks/economics.py ADDED
@@ -0,0 +1,54 @@
1
+ """Block economics — the UPIT value model with per-block optimal destination.
2
+
3
+ A block's net value is the better of its two destinations, exactly the semantics of a MineLib
4
+ ``.upit`` column (verified against newman1, where the published net value equals
5
+ max(value-if-wasted, value-if-processed)):
6
+
7
+ - waste: ``-mining_cost * tonnage``
8
+ - ore: ``(grade * recovery * price - processing_cost) * tonnage - mining_cost * tonnage``
9
+
10
+ The floating cutoff falls out of the max: a block is ore iff processing it beats wasting it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ import numpy as np
18
+
19
+ from .fields import Deposit
20
+
21
+ __all__ = ["Econ", "block_values", "is_ore", "cutoff_grade"]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Econ:
26
+ """price: $/t of recovered metal · recovery: 0..1 · costs: $/t mined / $/t milled."""
27
+
28
+ price: float = 9000.0
29
+ recovery: float = 0.88
30
+ mining_cost: float = 2.5
31
+ processing_cost: float = 9.0
32
+
33
+ def __post_init__(self) -> None:
34
+ if not (0 < self.recovery <= 1):
35
+ raise ValueError(f"recovery must be in (0,1], got {self.recovery}")
36
+ if self.price <= 0 or self.mining_cost < 0 or self.processing_cost < 0:
37
+ raise ValueError("price must be positive; costs non-negative")
38
+
39
+
40
+ def cutoff_grade(econ: Econ) -> float:
41
+ """The grade above which processing beats wasting: processing_cost / (recovery * price)."""
42
+ return econ.processing_cost / (econ.recovery * econ.price)
43
+
44
+
45
+ def block_values(dep: Deposit, econ: Econ) -> np.ndarray:
46
+ """Net value per block (float64) at the optimal destination — the ``.upit`` column."""
47
+ waste = -econ.mining_cost * dep.tonnage
48
+ ore = (dep.grade * econ.recovery * econ.price - econ.processing_cost) * dep.tonnage + waste
49
+ return np.maximum(waste, ore)
50
+
51
+
52
+ def is_ore(dep: Deposit, econ: Econ) -> np.ndarray:
53
+ """Boolean per block: does processing beat wasting?"""
54
+ return dep.grade > cutoff_grade(econ)
@@ -0,0 +1,177 @@
1
+ """Extraction state + loading faces — the bridge from a static pit to an OPERATING mine.
2
+
3
+ Given the exact pit (``in_pit``) and a mining progress fraction, benches are extracted top-down
4
+ (the only physical order): the state says which levels are fully out, which level is the active
5
+ bench, and which pit blocks remain. Loading FACES are seeded k-means clusters of the remaining
6
+ active-bench blocks — each face is a shovel position with its local mean grade, ore/waste split
7
+ and available tonnage. This is what a haulage simulator (e.g. minehaulsim) consumes to ground
8
+ truck cycles in geology: grade at face, bench elevation, ore vs waste destination.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+
17
+ from ._rng import stream
18
+ from .economics import Econ, cutoff_grade
19
+ from .fields import Deposit
20
+ from .grid import BlockGrid
21
+
22
+ __all__ = ["Face", "ExtractionState", "extraction_state", "loading_faces"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Face:
27
+ """One loading position on the active bench."""
28
+
29
+ face_id: int
30
+ level: int
31
+ x: float # centroid, block units
32
+ y: float
33
+ n_blocks: int
34
+ tonnes: float
35
+ mean_grade: float
36
+ ore_fraction: float # by tonnage, at the given cutoff
37
+ elevation_m: float # level * dz (bench floor height above the deepest level)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ExtractionState:
42
+ """Benches above ``active_level`` are fully extracted (within the pit); the active bench is
43
+ partially extracted per ``progress``. ``remaining`` marks in-pit blocks not yet mined."""
44
+
45
+ active_level: int
46
+ progress: float
47
+ extracted: np.ndarray # bool per block
48
+ remaining: np.ndarray # bool per block (in_pit & ~extracted)
49
+ tonnes_extracted: float
50
+ tonnes_remaining: float
51
+
52
+
53
+ def _pit_levels(grid: BlockGrid, in_pit: np.ndarray) -> list[int]:
54
+ """Levels that contain pit blocks, ordered TOP-DOWN (mining order)."""
55
+ per = grid.nx * grid.ny
56
+ levels = [lv for lv in range(grid.nz) if in_pit[lv * per : (lv + 1) * per].any()]
57
+ return list(reversed(levels))
58
+
59
+
60
+ def extraction_state(dep: Deposit, in_pit: np.ndarray, progress: float, seed: int = 1) -> ExtractionState:
61
+ """Extract ``progress`` (0..1) of the pit TONNAGE top-down; the active bench is partial.
62
+
63
+ Within the active bench, blocks leave in a seeded deterministic order (a centre-out sweep with
64
+ jitter), so a given (pit, progress, seed) always yields the same state.
65
+ """
66
+ if not (0.0 <= progress <= 1.0):
67
+ raise ValueError(f"progress must be in [0,1], got {progress}")
68
+ grid = dep.grid
69
+ in_pit = np.asarray(in_pit, dtype=bool)
70
+ total = float(dep.tonnage[in_pit].sum())
71
+ target = progress * total
72
+ extracted = np.zeros(grid.n_blocks, dtype=bool)
73
+ mined = 0.0
74
+ active = grid.surface_level
75
+ per = grid.nx * grid.ny
76
+ rng = stream(seed, "extraction:order")
77
+
78
+ for lv in _pit_levels(grid, in_pit):
79
+ active = lv
80
+ sl = slice(lv * per, (lv + 1) * per)
81
+ idxs = np.nonzero(in_pit[sl])[0] + lv * per
82
+ lv_tonnes = float(dep.tonnage[idxs].sum())
83
+ if mined + lv_tonnes <= target or lv_tonnes == 0.0:
84
+ extracted[idxs] = True
85
+ mined += lv_tonnes
86
+ if mined >= target:
87
+ break
88
+ continue
89
+ # partial bench: deterministic centre-out order with seeded jitter
90
+ ix = idxs % grid.nx
91
+ iy = (idxs // grid.nx) % grid.ny
92
+ cx = (grid.nx - 1) / 2.0
93
+ cy = (grid.ny - 1) / 2.0
94
+ key = np.hypot(ix - cx, iy - cy) + 0.25 * rng.random(idxs.shape[0])
95
+ order = idxs[np.argsort(key, kind="stable")]
96
+ csum = np.cumsum(dep.tonnage[order])
97
+ need = target - mined
98
+ k = 0 if need <= 0 else int(np.searchsorted(csum, need, side="left")) + 1
99
+ take = order[: min(k, order.shape[0])]
100
+ extracted[take] = True
101
+ mined += float(dep.tonnage[take].sum())
102
+ break
103
+
104
+ remaining = in_pit & ~extracted
105
+ return ExtractionState(
106
+ active_level=active,
107
+ progress=progress,
108
+ extracted=extracted,
109
+ remaining=remaining,
110
+ tonnes_extracted=mined,
111
+ tonnes_remaining=float(dep.tonnage[remaining].sum()),
112
+ )
113
+
114
+
115
+ def loading_faces(
116
+ dep: Deposit,
117
+ state: ExtractionState,
118
+ econ: Econ,
119
+ n_faces: int = 3,
120
+ seed: int = 1,
121
+ ) -> list[Face]:
122
+ """Cluster the remaining ACTIVE-bench blocks into ``n_faces`` seeded k-means faces."""
123
+ if n_faces < 1:
124
+ raise ValueError("n_faces must be >= 1")
125
+ grid = dep.grid
126
+ per = grid.nx * grid.ny
127
+ lv = state.active_level
128
+ sl = slice(lv * per, (lv + 1) * per)
129
+ idxs = np.nonzero(state.remaining[sl])[0] + lv * per
130
+ if idxs.shape[0] == 0: # bench exhausted: fall back to the next level with remaining blocks
131
+ rem_levels = [x for x in _pit_levels(grid, state.remaining) if state.remaining[x * per : (x + 1) * per].any()]
132
+ if not rem_levels:
133
+ return []
134
+ lv = rem_levels[0]
135
+ sl = slice(lv * per, (lv + 1) * per)
136
+ idxs = np.nonzero(state.remaining[sl])[0] + lv * per
137
+
138
+ ix = (idxs % grid.nx).astype(np.float64)
139
+ iy = ((idxs // grid.nx) % grid.ny).astype(np.float64)
140
+ pts = np.stack([ix, iy], axis=1)
141
+ k = min(n_faces, idxs.shape[0])
142
+
143
+ rng = stream(seed, f"faces:level{lv}")
144
+ centroids = pts[rng.choice(pts.shape[0], size=k, replace=False)]
145
+ assign = np.zeros(pts.shape[0], dtype=np.int64)
146
+ for _ in range(12): # Lloyd iterations — plenty for bench-scale point sets
147
+ d2 = ((pts[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
148
+ assign = d2.argmin(axis=1)
149
+ for c in range(k):
150
+ sel = assign == c
151
+ if sel.any():
152
+ centroids[c] = pts[sel].mean(axis=0)
153
+
154
+ cutoff = cutoff_grade(econ)
155
+ faces: list[Face] = []
156
+ for c in range(k):
157
+ sel = idxs[assign == c]
158
+ if sel.shape[0] == 0:
159
+ continue
160
+ t = dep.tonnage[sel]
161
+ g = dep.grade[sel]
162
+ tt = float(t.sum())
163
+ ore_t = float(t[g > cutoff].sum())
164
+ faces.append(
165
+ Face(
166
+ face_id=c,
167
+ level=lv,
168
+ x=float(centroids[c, 0]),
169
+ y=float(centroids[c, 1]),
170
+ n_blocks=int(sel.shape[0]),
171
+ tonnes=tt,
172
+ mean_grade=float((g * t).sum() / tt) if tt > 0 else 0.0,
173
+ ore_fraction=ore_t / tt if tt > 0 else 0.0,
174
+ elevation_m=lv * grid.dz,
175
+ )
176
+ )
177
+ return faces
oreblocks/fields.py ADDED
@@ -0,0 +1,146 @@
1
+ """Seeded synthetic deposits — geostatistically plausible grade fields on a block grid.
2
+
3
+ Clearly SYNTHETIC (no real drillholes), but built the way real deposits are described: a
4
+ deterministic grade trend (the geological shape) plus spatially-correlated noise (a box-smoothed
5
+ white field standing in for a variogram range), so downstream optimisers face non-trivial
6
+ ore/waste and slope trade-offs. Everything is seeded — byte-identical given (archetype, dims,
7
+ seed). The four archetypes mirror the CAOS PitForge teaching set:
8
+
9
+ - ``porphyry`` — a buried ellipsoidal high-grade shell (broad bowl pits)
10
+ - ``vein`` — a dipping tabular zone (narrow steep pits)
11
+ - ``layered`` — horizontal stratabound bands
12
+ - ``core_halo`` — a rich core inside a broad low-grade halo
13
+
14
+ Levels increase UPWARD (grid convention); the trend functions are written in depth fractions so
15
+ the shapes match their depth-down originals exactly.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ import numpy as np
23
+
24
+ from ._rng import stream
25
+ from .grid import BlockGrid
26
+
27
+ __all__ = ["ARCHETYPES", "Deposit", "make_deposit"]
28
+
29
+ ARCHETYPES = ("porphyry", "vein", "layered", "core_halo")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Deposit:
34
+ """A block model: grid + per-block grade (mass fraction), tonnage (t) and density (t/m3)."""
35
+
36
+ grid: BlockGrid
37
+ grade: np.ndarray
38
+ tonnage: np.ndarray
39
+ density: np.ndarray
40
+ meta: dict = field(default_factory=dict)
41
+
42
+ def __post_init__(self) -> None:
43
+ n = self.grid.n_blocks
44
+ for name in ("grade", "tonnage", "density"):
45
+ a = getattr(self, name)
46
+ if a.shape != (n,):
47
+ raise ValueError(f"{name} must be a flat array of {n} blocks, got {a.shape}")
48
+
49
+
50
+ def _smooth(vol: np.ndarray, passes: int) -> np.ndarray:
51
+ """A few 3x3x3 box-blur passes (edge-corrected) give white noise a correlation length."""
52
+ out = vol.astype(np.float64, copy=True)
53
+ ones = np.ones_like(out)
54
+ for _ in range(passes):
55
+ acc = np.zeros_like(out)
56
+ cnt = np.zeros_like(out)
57
+ for dz in (-1, 0, 1):
58
+ for dy in (-1, 0, 1):
59
+ for dx in (-1, 0, 1):
60
+ src = out[
61
+ max(0, -dz) : out.shape[0] - max(0, dz),
62
+ max(0, -dy) : out.shape[1] - max(0, dy),
63
+ max(0, -dx) : out.shape[2] - max(0, dx),
64
+ ]
65
+ csrc = ones[
66
+ max(0, -dz) : out.shape[0] - max(0, dz),
67
+ max(0, -dy) : out.shape[1] - max(0, dy),
68
+ max(0, -dx) : out.shape[2] - max(0, dx),
69
+ ]
70
+ acc[
71
+ max(0, dz) : out.shape[0] - max(0, -dz),
72
+ max(0, dy) : out.shape[1] - max(0, -dy),
73
+ max(0, dx) : out.shape[2] - max(0, -dx),
74
+ ] += src
75
+ cnt[
76
+ max(0, dz) : out.shape[0] - max(0, -dz),
77
+ max(0, dy) : out.shape[1] - max(0, -dy),
78
+ max(0, dx) : out.shape[2] - max(0, -dx),
79
+ ] += csrc
80
+ out = acc / cnt
81
+ return out
82
+
83
+
84
+ def _trend(archetype: str, fx: np.ndarray, fy: np.ndarray, fdepth: np.ndarray) -> np.ndarray:
85
+ """Relative grade shape in ~[0,1]; ``fdepth`` = 0 at the surface, 1 at the deepest level."""
86
+ cx = fx - 0.5
87
+ cy = fy - 0.5
88
+ if archetype == "porphyry":
89
+ r = np.sqrt(cx * cx + cy * cy + (fdepth - 0.45) ** 2)
90
+ return np.maximum(0.0, 1.0 - np.abs(r - 0.22) / 0.28)
91
+ if archetype == "vein":
92
+ plane = cx * 0.8 + (fdepth - 0.5) * 0.6
93
+ return np.maximum(0.0, 1.0 - np.abs(plane) / 0.12)
94
+ if archetype == "layered":
95
+ return 0.5 + 0.5 * np.cos(fdepth * np.pi * 4)
96
+ if archetype == "core_halo":
97
+ r = np.sqrt(cx * cx + cy * cy + (fdepth - 0.5) ** 2)
98
+ return np.maximum(0.0, 1.0 - r / 0.45) ** 1.6
99
+ raise ValueError(f"unknown archetype {archetype!r}; expected one of {ARCHETYPES}")
100
+
101
+
102
+ def make_deposit(
103
+ grid: BlockGrid,
104
+ archetype: str,
105
+ seed: int = 1,
106
+ *,
107
+ peak_grade: float = 0.02,
108
+ background: float = 0.001,
109
+ density: float = 2.7,
110
+ noise: float = 0.35,
111
+ name: str | None = None,
112
+ ) -> Deposit:
113
+ """Build a seeded synthetic deposit on ``grid``. Grades are mass fractions in [0, 1]."""
114
+ if archetype not in ARCHETYPES:
115
+ raise ValueError(f"unknown archetype {archetype!r}; expected one of {ARCHETYPES}")
116
+ rng = stream(seed, f"deposit:{archetype}")
117
+ white = rng.random((grid.nz, grid.ny, grid.nx)) - 0.5
118
+ corr = _smooth(white, passes=3)
119
+
120
+ level = np.arange(grid.nz, dtype=np.float64)
121
+ fdepth_1d = 1.0 - (level / (grid.nz - 1) if grid.nz > 1 else np.full(1, 0.5))
122
+ fx_1d = np.arange(grid.nx, dtype=np.float64) / (grid.nx - 1) if grid.nx > 1 else np.full(1, 0.5)
123
+ fy_1d = np.arange(grid.ny, dtype=np.float64) / (grid.ny - 1) if grid.ny > 1 else np.full(1, 0.5)
124
+ fdepth, fy, fx = np.meshgrid(fdepth_1d, fy_1d, fx_1d, indexing="ij")
125
+
126
+ shape = _trend(archetype, fx, fy, fdepth)
127
+ grade = background + (peak_grade - background) * np.maximum(0.0, shape + noise * corr)
128
+ grade = np.clip(grade, 0.0, 1.0).reshape(-1)
129
+
130
+ block_tonnes = grid.block_volume * density
131
+ return Deposit(
132
+ grid=grid,
133
+ grade=grade,
134
+ tonnage=np.full(grid.n_blocks, block_tonnes),
135
+ density=np.full(grid.n_blocks, density),
136
+ meta={
137
+ "archetype": archetype,
138
+ "seed": seed,
139
+ "peak_grade": peak_grade,
140
+ "background": background,
141
+ "noise": noise,
142
+ "grade_unit": "mass fraction",
143
+ "name": name or archetype,
144
+ "synthetic": True,
145
+ },
146
+ )
oreblocks/grid.py ADDED
@@ -0,0 +1,76 @@
1
+ """Regular 3-D block grid with MineLib's level convention.
2
+
3
+ The vertical index is the LEVEL and increases UPWARD: level 0 is the deepest bench, level
4
+ ``nz - 1`` is the surface bench. This is the convention published MineLib instances use (verified
5
+ against newman1: a block's predecessors sit one level ABOVE it), so everything oreblocks emits is
6
+ directly comparable with the published library. Viewers that draw depth-down (z=0 at the surface)
7
+ simply flip: ``z_down = (nz - 1) - level``.
8
+
9
+ Flat indexing is ``index = (level * ny + iy) * nx + ix`` — x fastest, then y, then level.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ import numpy as np
17
+
18
+ __all__ = ["BlockGrid"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BlockGrid:
23
+ """Grid dimensions + block size (metres). ``nz`` counts benches (levels, up)."""
24
+
25
+ nx: int
26
+ ny: int
27
+ nz: int
28
+ dx: float = 10.0
29
+ dy: float = 10.0
30
+ dz: float = 10.0
31
+
32
+ def __post_init__(self) -> None:
33
+ if min(self.nx, self.ny, self.nz) < 1:
34
+ raise ValueError(f"grid dims must be >= 1, got {self.nx}x{self.ny}x{self.nz}")
35
+ if min(self.dx, self.dy, self.dz) <= 0:
36
+ raise ValueError("block size must be positive")
37
+
38
+ @property
39
+ def n_blocks(self) -> int:
40
+ return self.nx * self.ny * self.nz
41
+
42
+ @property
43
+ def surface_level(self) -> int:
44
+ return self.nz - 1
45
+
46
+ @property
47
+ def block_volume(self) -> float:
48
+ return self.dx * self.dy * self.dz
49
+
50
+ def index(self, ix: int, iy: int, level: int) -> int:
51
+ """Flat index of the block at (ix, iy, level). Bounds-checked."""
52
+ if not (0 <= ix < self.nx and 0 <= iy < self.ny and 0 <= level < self.nz):
53
+ raise IndexError(f"({ix},{iy},{level}) outside {self.nx}x{self.ny}x{self.nz}")
54
+ return (level * self.ny + iy) * self.nx + ix
55
+
56
+ def coords(self, i: int) -> tuple[int, int, int]:
57
+ """(ix, iy, level) of flat index ``i``."""
58
+ if not (0 <= i < self.n_blocks):
59
+ raise IndexError(f"flat index {i} outside 0..{self.n_blocks - 1}")
60
+ level, rem = divmod(i, self.nx * self.ny)
61
+ iy, ix = divmod(rem, self.nx)
62
+ return ix, iy, level
63
+
64
+ def coord_arrays(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
65
+ """(x, y, level) int32 arrays for every block, in flat order — the MineLib id order."""
66
+ ii = np.arange(self.n_blocks, dtype=np.int64)
67
+ level, rem = np.divmod(ii, self.nx * self.ny)
68
+ iy, ix = np.divmod(rem, self.nx)
69
+ return ix.astype(np.int32), iy.astype(np.int32), level.astype(np.int32)
70
+
71
+ def level_slice(self, level: int) -> slice:
72
+ """Flat-index slice covering one whole level."""
73
+ if not (0 <= level < self.nz):
74
+ raise IndexError(f"level {level} outside 0..{self.nz - 1}")
75
+ per = self.nx * self.ny
76
+ return slice(level * per, (level + 1) * per)