Skip to content

Commit 5a3b845

Browse files
authored
Merge pull request #879 from MiraGeoscience/GEOPY-2602
GEOPY-2602: Validate grid size for clipping 2D grids for a given amount of RAM
2 parents c0aebed + 8a82284 commit 5a3b845

7 files changed

Lines changed: 245 additions & 75 deletions

File tree

geoh5py/io/h5_reader.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626

2727
import h5py
2828
import numpy as np
29+
import psutil
2930

3031
from ..shared import FLOAT_NDV, fetch_h5_handle
32+
from ..shared.exceptions import MemoryValidationError
3133
from ..shared.utils import (
3234
INV_KEY_MAP,
3335
KEY_MAP,
@@ -38,6 +40,34 @@
3840
)
3941

4042

43+
def safe_load_dataset(value: h5py.Dataset, key: str, buffer: float = 0.8) -> np.ndarray:
44+
"""
45+
Attempt to load an h5py dataset, checking memory availability first.
46+
47+
:param value: h5py Dataset to load.
48+
:param key: Dataset key name (for error messages).
49+
:param buffer: Fraction of available memory to use as a threshold (default 0.8).
50+
51+
:raises MemoryValidationError: If estimated memory usage exceeds threshold
52+
or if loading fails due to MemoryError.
53+
54+
:return: Loaded numpy array or None if memory is insufficient.
55+
"""
56+
if not 0 < buffer <= 1:
57+
raise ValueError("Buffer must be between 0 and 1.")
58+
59+
estimated_bytes = value.size * value.dtype.itemsize
60+
available_bytes = psutil.virtual_memory().available * buffer
61+
62+
if estimated_bytes > available_bytes:
63+
raise MemoryValidationError(key, value, available_bytes)
64+
65+
try:
66+
return value[:]
67+
except MemoryError as err:
68+
raise MemoryValidationError(key, value, available_bytes) from err
69+
70+
4171
class H5Reader:
4272
"""
4373
Class to read information from a geoh5 file.
@@ -96,7 +126,11 @@ def fetch_attributes(
96126
and isinstance(value, h5py.Dataset)
97127
and value.ndim > 0
98128
):
99-
attributes[INV_KEY_MAP[key]] = value[:]
129+
attributes[INV_KEY_MAP[key]] = safe_load_dataset(
130+
value,
131+
f"{entity_type} {as_str_if_uuid(uid)} attribute '{key}'",
132+
0.8,
133+
)
100134

101135
if "Type" in entity:
102136
type_attributes = cls.fetch_type_attributes(entity["Type"])
@@ -472,7 +506,8 @@ def fetch_values(
472506
name = list(h5file)[0]
473507

474508
try:
475-
values = np.r_[h5file[name]["Data"][as_str_if_uuid(uid)]["Data"]]
509+
dataset = h5file[name]["Data"][as_str_if_uuid(uid)]["Data"]
510+
values = safe_load_dataset(dataset, f"Data {as_str_if_uuid(uid)}")
476511
if isinstance(values[0], (str, bytes)):
477512
values = np.asarray([as_str_if_utf8_bytes(val) for val in values])
478513
if len(values) == 1:

geoh5py/shared/exceptions.py

Lines changed: 61 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# You should have received a copy of the GNU Lesser General Public License '
1717
# along with geoh5py. If not, see <https://www.gnu.org/licenses/>. '
1818
# ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
19-
19+
# pylint: disable=arguments-differ
2020

2121
from __future__ import annotations
2222

@@ -36,55 +36,49 @@ class Geoh5FileClosedError(ABC, Exception):
3636

3737

3838
class BaseValidationError(ABC, Exception):
39-
"""Base class for custom exceptions."""
39+
"""
40+
Base class for custom exceptions.
41+
"""
42+
43+
def __init__(self, *args, **kwargs):
44+
super().__init__(self.message(*args, **kwargs))
4045

4146
@classmethod
4247
@abstractmethod
43-
def message(cls, name, value, validation):
44-
"""Builds custom error message."""
45-
raise NotImplementedError()
48+
def message(cls, *args, **kwargs) -> str:
49+
"""
50+
Builds custom error message.
4651
52+
Each subclass of BaseValidationError should implement the message class method.
53+
"""
4754

48-
class JSONParameterValidationError(Exception):
49-
"""Error on uuid validation."""
5055

51-
def __init__(self, name: str, err: str):
52-
super().__init__(JSONParameterValidationError.message(name, err))
56+
class JSONParameterValidationError(BaseValidationError):
57+
"""Error on uuid validation."""
5358

5459
@classmethod
55-
def message(cls, name, err):
60+
def message(cls, name: str, err: str) -> str:
5661
return f"Malformed ui.json dictionary for parameter '{name}'. {err}"
5762

5863

5964
class OptionalValidationError(BaseValidationError):
6065
"""Error if None value provided to non-optional parameter."""
6166

62-
def __init__(
63-
self,
64-
name: str,
65-
value: Any | None,
66-
validation: bool,
67-
):
68-
super().__init__(OptionalValidationError.message(name, value, validation))
69-
7067
@classmethod
71-
def message(cls, name, value, validation):
68+
def message(cls, name: str, *_) -> str:
7269
return f"Cannot set a None value to non-optional parameter: {name}."
7370

7471

7572
class AssociationValidationError(BaseValidationError):
7673
"""Error on association between child and parent entity validation."""
7774

78-
def __init__(
79-
self,
75+
@classmethod
76+
def message(
77+
cls,
8078
name: str,
8179
value: Entity | PropertyGroup | UUID,
8280
validation: Entity | Workspace,
83-
):
84-
super().__init__(AssociationValidationError.message(name, value, validation))
85-
86-
@classmethod
87-
def message(cls, name, value, validation):
81+
) -> str:
8882
return (
8983
f"Property '{name}' with value: '{value}' must be "
9084
f"a child entity of parent {validation}"
@@ -94,46 +88,38 @@ def message(cls, name, value, validation):
9488
class PropertyGroupValidationError(BaseValidationError):
9589
"""Error on property group validation."""
9690

97-
def __init__(self, name: str, value: PropertyGroup, validation: list[str]):
98-
super().__init__(PropertyGroupValidationError.message(name, value, validation))
99-
10091
@classmethod
101-
def message(cls, name, value, validation):
92+
def message(cls, name: str, value: PropertyGroup, validation: list[str]) -> str:
10293
return (
10394
f"Property group for '{name}' must be of type '{validation}'. "
10495
f"Provided '{value.name}' of type '{value.property_group_type}'"
10596
)
10697

10798

10899
class AtLeastOneValidationError(BaseValidationError):
109-
def __init__(self, name: str, value: list[str]):
110-
super().__init__(AtLeastOneValidationError.message(name, value))
100+
"""Error on at least one validation."""
111101

112102
@classmethod
113-
def message(cls, name, value, validation=None):
103+
def message(cls, name: str, value: list[str], *_) -> str:
114104
opts = "'" + "', '".join(str(k) for k in value) + "'"
115105
return f"Must provide at least one {name}. Options are: {opts}"
116106

117107

118108
class RequiredValidationError(BaseValidationError):
119-
def __init__(self, name: str):
120-
super().__init__(RequiredValidationError.message(name))
109+
"""Error on required parameter validation."""
121110

122111
@classmethod
123-
def message(cls, name, value=None, validation=None):
112+
def message(cls, name: str, *_) -> str:
124113
return f"Missing required parameter: '{name}'."
125114

126115

127116
class ShapeValidationError(BaseValidationError):
128117
"""Error on shape validation."""
129118

130-
def __init__(
131-
self, name: str, value: tuple[int, ...], validation: tuple[int, ...] | str
132-
):
133-
super().__init__(ShapeValidationError.message(name, value, validation))
134-
135-
@staticmethod
136-
def message(name, value, validation):
119+
@classmethod
120+
def message(
121+
cls, name: str, value: tuple[int, ...], validation: tuple[int, ...] | str
122+
) -> str:
137123
return (
138124
f"Parameter '{name}' with shape {value} was provided. "
139125
f"Expected {validation}."
@@ -143,11 +129,8 @@ def message(name, value, validation):
143129
class TypeValidationError(BaseValidationError):
144130
"""Error on type validation."""
145131

146-
def __init__(self, name: str, value: str, validation: str | list[str]):
147-
super().__init__(TypeValidationError.message(name, value, validation))
148-
149-
@staticmethod
150-
def message(name, value, validation):
132+
@classmethod
133+
def message(cls, name: str, value: str, validation: str | list[str]) -> str:
151134
return f"Type '{value}' provided for '{name}' is invalid." + iterable_message(
152135
validation
153136
)
@@ -156,38 +139,50 @@ def message(name, value, validation):
156139
class UUIDValidationError(BaseValidationError):
157140
"""Error on uuid string validation."""
158141

159-
def __init__(self, name: str, value: str):
160-
super().__init__(UUIDValidationError.message(name, value))
161-
162-
@staticmethod
163-
def message(name, value, validation=None):
142+
@classmethod
143+
def message(cls, name: str, value: str, *_) -> str:
164144
return f"Parameter '{name}' with value '{value}' is not a valid uuid string."
165145

166146

167147
class ValueValidationError(BaseValidationError):
168148
"""Error on value validation."""
169149

170-
def __init__(self, name: str, value: Any, validation: list[Any]):
171-
super().__init__(ValueValidationError.message(name, value, validation))
172-
173-
@staticmethod
174-
def message(name, value, validation):
150+
@classmethod
151+
def message(cls, name: str, value: Any, validation: list[Any]) -> str:
175152
return f"Value '{value}' provided for '{name}' is invalid." + iterable_message(
176153
validation
177154
)
178155

179156

180-
def iterable_message(valid: list[Any] | None) -> str:
181-
"""Append possibly iterable valid: "Must be (one of): {valid}."."""
157+
class MemoryValidationError(BaseValidationError):
158+
"""Error on memory validation."""
159+
160+
@classmethod
161+
def message(cls, name: str, value: Any, validation: float) -> str:
162+
return (
163+
f"Parameter '{name}' with value '{value}' "
164+
f"exceeds memory limit of {validation / 1e6} MB."
165+
)
166+
167+
168+
def iterable_message(valid: str | list[Any] | None) -> str:
169+
"""
170+
Append possibly iterable valid: "Must be (one of): {valid}".
171+
172+
:param valid: Valid value(s) to include in message. Can be a string, list of values, or None.
173+
174+
:return: Message string indicating valid value(s).
175+
"""
176+
182177
if valid is None:
183-
msg = ""
184-
elif iterable(valid, checklen=True):
178+
return ""
179+
if isinstance(valid, str):
180+
return f" Must be: '{valid}'."
181+
if iterable(valid, checklen=True):
185182
vstr = "'" + "', '".join(str(k) for k in valid) + "'"
186-
msg = f" Must be one of: {vstr}."
187-
else:
188-
msg = f" Must be: '{valid[0]}'."
183+
return f" Must be one of: {vstr}."
189184

190-
return msg
185+
return f" Must be: '{valid[0]}'."
191186

192187

193188
def iterable(value: Any, checklen: bool = False) -> bool:

geoh5py/shared/utils.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,8 +1439,6 @@ def enum_name_to_str(value: Any | Enum) -> Any | str:
14391439
return [enum_name_to_str(v) for v in value]
14401440

14411441
if isinstance(value, Enum):
1442-
if isinstance(value, str):
1443-
return str(value)
14441442
return value.name.capitalize()
14451443

14461444
return value

geoh5py/ui_json/ui_json.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,8 @@ def to_params(
337337
with (
338338
fetch_active_workspace(workspace)
339339
if workspace
340-
else Workspace(self.geoh5, mode="r") as geoh5
341-
):
340+
else Workspace(self.geoh5, mode="r")
341+
) as geoh5:
342342
if geoh5 is None:
343343
raise ValueError("Workspace cannot be None.")
344344

@@ -471,7 +471,7 @@ def _get_dependency_links(
471471
For each form, there can be a group dependency ('group') to a leading
472472
form ('group_optional') and/or a direct dependency between forms ('dependency').
473473
474-
A direct dependency controls the enabled state tow ways, while the group dependency
474+
A direct dependency controls the enabled state two ways, while the group dependency
475475
controls the enabled state only from the lead form to its dependents.
476476
477477
:returns: Tuple of group dependencies and direct form dependencies.

poetry.lock

Lines changed: 37 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ h5py = "^3.15.0"
6767
numpy = "~2.4.0"
6868
Pillow = "~12.1.0"
6969
pydantic = "~2.12.0"
70+
psutil = "^7.2.2"
7071

7172
[tool.poetry.group.dev.dependencies]
7273
lockfile = "^0.12.2"

0 commit comments

Comments
 (0)