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 +46 -0
- oreblocks/_rng.py +20 -0
- oreblocks/economics.py +54 -0
- oreblocks/extraction.py +177 -0
- oreblocks/fields.py +146 -0
- oreblocks/grid.py +76 -0
- oreblocks/minelib_io.py +174 -0
- oreblocks/precedence.py +89 -0
- oreblocks/twins.py +84 -0
- oreblocks/upit.py +178 -0
- oreblocks-0.1.0.dist-info/METADATA +91 -0
- oreblocks-0.1.0.dist-info/RECORD +15 -0
- oreblocks-0.1.0.dist-info/WHEEL +5 -0
- oreblocks-0.1.0.dist-info/licenses/LICENSE +202 -0
- oreblocks-0.1.0.dist-info/top_level.txt +1 -0
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)
|
oreblocks/extraction.py
ADDED
|
@@ -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)
|