fitsbolt 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.
fitsbolt/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ # fitsbolt - A Python package for image loading and processing
2
+ # Copyright (C) <2025> <Ruhberg>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ # Core imports
18
+ from .image_loader import load_and_process_images
19
+ from .read import read_images
20
+ from .resize import resize_images
21
+
22
+ # Import from submodules
23
+ from .normalisation.NormalisationMethod import NormalisationMethod
24
+ from .normalisation.normalisation import normalise_images
25
+ from .cfg.create_config import create_config, validate_config, SUPPORTED_IMAGE_EXTENSIONS
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ # Main functionality
31
+ "load_and_process_images",
32
+ "SUPPORTED_IMAGE_EXTENSIONS",
33
+ # Individual processing functions
34
+ "read_images",
35
+ "normalise_images",
36
+ "resize_images",
37
+ # Normalisation module
38
+ "NormalisationMethod",
39
+ "normalise_image",
40
+ # Configuration module
41
+ "create_config",
42
+ "validate_config",
43
+ ]
@@ -0,0 +1,22 @@
1
+ # fitsbolt - A Python package for image loading and processing
2
+ # Copyright (C) <2025> <Ruhberg>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ from .create_config import create_config, validate_config
18
+
19
+ __all__ = [
20
+ "create_config",
21
+ "validate_config",
22
+ ]
@@ -0,0 +1,486 @@
1
+ # fitsbolt - A Python package for image loading and processing
2
+ # Copyright (C) <2025> <Ruhberg>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import numbers
18
+ import numpy as np
19
+ from dotmap import DotMap
20
+ from loguru import logger
21
+
22
+ from fitsbolt.normalisation.NormalisationMethod import NormalisationMethod
23
+
24
+ SUPPORTED_IMAGE_EXTENSIONS = {".fits", ".jpg", ".jpeg", ".png", ".tiff"}
25
+
26
+
27
+ def create_config(
28
+ output_dtype=np.uint8,
29
+ size=[224, 224],
30
+ fits_extension=None,
31
+ interpolation_order=1,
32
+ n_output_channels=3,
33
+ normalisation_method=NormalisationMethod.CONVERSION_ONLY,
34
+ channel_combination=None,
35
+ num_workers=4,
36
+ norm_maximum_value=None,
37
+ norm_minimum_value=None,
38
+ norm_log_calculate_minimum_value=False,
39
+ norm_crop_for_maximum_value=None,
40
+ norm_asinh_scale=[0.7, 0.7, 0.7],
41
+ norm_asinh_clip=[99.8, 99.8, 99.8],
42
+ log_level="INFO",
43
+ force_dtype=True,
44
+ ):
45
+ """Create a configuration object for loading and processing astronomical data.
46
+
47
+ Args:
48
+ output_dtype (type, optional): Data type for output images. Defaults to np.uint8.
49
+ size (list, optional): Target size for image resizing. Defaults to [224, 224]. If None, no resizing.
50
+ fits_extension (list, optional): Extension(s) to use when loading FITS files. Defaults to None.
51
+ interpolation_order (int, optional): Order of interpolation for resizing with skimage, 0-5. Defaults to 1.
52
+ n_output_channels (int,optional): number of output channels. Defaults to 3.
53
+ normalisation_method (NormalisationMethod, optional): Method for normalising images.
54
+ Defaults to NormalisationMethod.CONVERSION_ONLY.
55
+ channel_combination (np.ndarray, optional): n_output x fits_extension sized np array for channel mapping& lerp.
56
+ Defaults to None, which will map extensions to channels either
57
+ 1:1 if applicable or 1:n_output if only 1 input is provided.
58
+ num_workers (int, optional): Number of worker threads for data loading. Defaults to 4.
59
+
60
+ norm_maximum_value (type, optional): Maximum value for normalisation. Defaults to None implying dynamic.
61
+ norm_minimum_value (type, optional): Minimum value for normalisation. Defaults to None implying dynamic.
62
+ norm_log_calculate_minimum_value (bool, optional): If True, calculates the minimum value for log scaling.
63
+ Defaults to False.
64
+ norm_crop_for_maximum_value (type, optional): Crops the image for maximum value. Defaults to None.
65
+ norm_asinh_scale (list, optional): Scale factors for asinh normalisation. Defaults to [0.7, 0.7, 0.7].
66
+ norm_asinh_clip (list, optional): Clip values for asinh normalisation. Defaults to [99.8, 99.8, 99.8].
67
+ log_level (str, optional): Logging level. Defaults to "INFO".
68
+ force_dtype (bool, optional): If True, forces the output to maintain the original dtype after tensor operations
69
+ like channel combination. Defaults to True.
70
+
71
+ Returns:
72
+ _type_: _description_
73
+ """
74
+ cfg = DotMap(_dynamic=False)
75
+ # Settings
76
+ cfg.log_level = log_level
77
+ cfg.output_dtype = output_dtype
78
+ cfg.size = size # tuple of (height, width)
79
+ cfg.n_output_channels = n_output_channels # int, normally 3 for R,G,B
80
+ cfg.num_workers = num_workers
81
+ cfg.force_dtype = force_dtype # Force output to maintain original dtype after tensor operations
82
+ # order of interpolation for resizing with skimage, 0-5
83
+ cfg.interpolation_order = interpolation_order
84
+ # Normalisation settings
85
+ cfg.normalisation_method = normalisation_method
86
+ # Optional normalisation settings
87
+ cfg.normalisation = DotMap()
88
+ cfg.normalisation.maximum_value = norm_maximum_value # None or float
89
+ cfg.normalisation.minimum_value = norm_minimum_value # None or float
90
+ cfg.normalisation.crop_for_maximum_value = (
91
+ norm_crop_for_maximum_value # None or integer tuple (height, width)
92
+ )
93
+ # Bool, if False assumes min value to be 0 or cfg.normalisation.minimum_value if not None
94
+ cfg.normalisation.log_calculate_minimum_value = norm_log_calculate_minimum_value
95
+ # only used if cfg.normalisation_method == NormalisationMethod.ASINH:
96
+ # asinh_scale list of 3 floats > 0, defining the scale for each channel (lower = higher stretch):
97
+ cfg.normalisation.asinh_scale = norm_asinh_scale
98
+ # asinh_clip list of 3 floats in ]0.,100.], defining the clip for each channel:
99
+ cfg.normalisation.asinh_clip = norm_asinh_clip
100
+
101
+ # FITS file handling settings
102
+ # Extension(s) to use when loading FITS files (can be int, string, or list of int/string)
103
+ cfg.fits_extension = fits_extension
104
+ # Dictionary defining how to combine FITS extensions into RGB channels, should contain lists of the same length
105
+ # as cfg.fits_extension, or empty lists - then the first three extensions will be used for RGB
106
+ if fits_extension is not None:
107
+ # Convert single extension to list format
108
+ if not isinstance(fits_extension, (list, tuple, np.ndarray)):
109
+ fits_extension = [fits_extension] # Convert to list for consistent handling
110
+ cfg.fits_extension = fits_extension
111
+
112
+ if channel_combination is None:
113
+ if not (len(fits_extension) in [1, n_output_channels]):
114
+ # fits extension does not match 1 channel (castable to greyscale) or n_output
115
+
116
+ raise ValueError(
117
+ "Length of fits_extensions does not match the specified number of output channels and"
118
+ + "no mapping via channelcombination is provided."
119
+ + f"Length fits_extension: {np.array(fits_extension).size}"
120
+ + f"Specified output channels: {n_output_channels}"
121
+ + f"-> set channel_combination to be a {n_output_channels}x{np.array(fits_extension).size}"
122
+ )
123
+ else:
124
+ # selecting n fits extensions but no combination will lead to a 1-1 mapping
125
+ extension_size = len(fits_extension)
126
+ combination_array = np.zeros((n_output_channels, extension_size))
127
+ if combination_array.shape[0] == combination_array.shape[1]:
128
+ # Identity matrix mapping
129
+ for i in range(0, combination_array.shape[0]):
130
+ combination_array[i, i] = 1
131
+ else:
132
+ # For the case where fits_extension has only 1 element
133
+ if extension_size == 1:
134
+ # Map all output channels to the single input channel
135
+ for i in range(0, combination_array.shape[0]):
136
+ combination_array[i, 0] = 1
137
+
138
+ # Debug output
139
+ print(f"Created channel_combination array with shape {combination_array.shape}")
140
+ print(f"Array contents: {combination_array}")
141
+
142
+ cfg.channel_combination = combination_array
143
+ else:
144
+
145
+ cfg.channel_combination = (
146
+ channel_combination # n_output x fits_extension sizes np array
147
+ )
148
+ validate_config(cfg)
149
+ return cfg
150
+
151
+
152
+ def _return_required_and_optional_keys():
153
+ """
154
+ Returns the configuration parameters in a unified format.
155
+
156
+ Returns:
157
+ dict: Dictionary with parameter_name as key and [dtype, min, max, optional, allowed_values] as value
158
+ - dtype: expected data type (str, int, float, bool, list, tuple, 'directory', 'file', 'special')
159
+ - min: minimum value (None if not applicable)
160
+ - max: maximum value (None if not applicable)
161
+ - optional: True if parameter is optional, False if required
162
+ - allowed_values: list of allowed values (None if not applicable)
163
+ """
164
+ config_spec = {
165
+ # Required positive integers
166
+ "num_workers": [int, 1, None, False, None],
167
+ "n_output_channels": [int, 1, None, False, None],
168
+ # Required boolean parameters
169
+ "normalisation.log_calculate_minimum_value": [bool, None, None, False, None],
170
+ "force_dtype": [bool, None, None, False, None],
171
+ # Required parameters with allowed values
172
+ "log_level": [
173
+ str,
174
+ None,
175
+ None,
176
+ False,
177
+ ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "TRACE"],
178
+ ],
179
+ # Required special parameters
180
+ "size": ["special_size", None, None, False, None],
181
+ "normalisation_method": ["special_normalisation_method", None, None, False, None],
182
+ "normalisation": ["special_normalisation", None, None, False, None],
183
+ "normalisation.asinh_scale": ["special_asinh_scale", None, None, False, None],
184
+ "normalisation.asinh_clip": ["special_asinh_clip", None, None, False, None],
185
+ "interpolation_order": [int, 0, 5, False, None], # 0-5 for skimage interpolation"
186
+ "output_dtype": [type, None, None, False, None],
187
+ # Optional numeric parameters
188
+ "normalisation.maximum_value": [float, None, None, True, None],
189
+ "normalisation.minimum_value": [float, None, None, True, None],
190
+ # Optional special parameters
191
+ "normalisation.crop_for_maximum_value": ["special_crop", None, None, True, None],
192
+ "fits_extension": ["special_fits_extension", None, None, True, None],
193
+ "channel_combination": ["special_channel_combination", None, None, True, None],
194
+ }
195
+
196
+ return config_spec
197
+
198
+
199
+ def _get_nested_value(cfg: DotMap, key: str):
200
+ """Get a nested value from the config using dot notation.
201
+
202
+ Args:
203
+ cfg (DotMap): Configuration object
204
+ key (str): Key in dot notation (e.g., 'normalisation.maximum_value')
205
+
206
+ Returns:
207
+ Any: Value from the config
208
+ """
209
+ current = cfg
210
+ for part in key.split("."):
211
+ try:
212
+ current = current[part]
213
+ except (KeyError, TypeError):
214
+ raise ValueError(f"Missing key in config: {key}")
215
+ return current
216
+
217
+
218
+ def _get_all_keys(cfg: DotMap, parent_key: str = ""):
219
+ """Get keys of the configuration in dot notation.
220
+
221
+ Args:
222
+ cfg (DotMap): Configuration dotmap
223
+ parent_key (str, optional): Parent key for nested values."".
224
+
225
+ Returns:
226
+ set: Set of all keys in dot notation
227
+ """
228
+ keys = set()
229
+ for key, value in cfg.items():
230
+ current_key = f"{parent_key}.{key}" if parent_key else key
231
+ keys.add(current_key)
232
+ if isinstance(value, DotMap):
233
+ keys.update(_get_all_keys(value, current_key))
234
+ return keys
235
+
236
+
237
+ def validate_config(cfg: DotMap, check_paths: bool = True) -> None:
238
+ """Validate configuration against required and optional keys specification.
239
+
240
+ Args:
241
+ cfg (DotMap): Configuration to validate
242
+ check_paths (bool): Whether to check if file and directory paths exist
243
+
244
+ Raises:
245
+ ValueError: If configuration is invalid
246
+ """
247
+ # Get configuration specification
248
+ config_spec = _return_required_and_optional_keys()
249
+
250
+ # Keep track of checked keys
251
+ expected_keys = set()
252
+
253
+ # Validate each parameter
254
+ for param_name, (dtype, min_val, max_val, optional, allowed_values) in config_spec.items():
255
+ expected_keys.add(param_name)
256
+
257
+ # Try to get the value, handle missing optional parameters
258
+ try:
259
+ value = _get_nested_value(cfg, param_name)
260
+ except ValueError:
261
+ if optional:
262
+ continue # Skip missing optional parameters
263
+ else:
264
+ raise ValueError(
265
+ f"Missing required parameter: {param_name}"
266
+ + f"(type: {dtype.__name__ if hasattr(dtype, '__name__') else dtype})"
267
+ )
268
+
269
+ # Skip validation for None values on optional parameters
270
+ if value is None and optional:
271
+ continue
272
+
273
+ # Helper function to format constraint info
274
+ def _format_constraints():
275
+ constraints = []
276
+ if min_val is not None:
277
+ constraints.append(f"min: {min_val}")
278
+ if max_val is not None:
279
+ constraints.append(f"max: {max_val}")
280
+ if allowed_values is not None:
281
+ constraints.append(f"allowed: {allowed_values}")
282
+ return f" ({', '.join(constraints)})" if constraints else ""
283
+
284
+ # Validate based on data type
285
+ if dtype == str:
286
+ if not isinstance(value, str):
287
+ raise ValueError(
288
+ f"{param_name} must be a string, got {type(value).__name__}{_format_constraints()}"
289
+ )
290
+ # Check allowed values for string types
291
+ if allowed_values is not None and value not in allowed_values:
292
+ raise ValueError(f"{param_name} must be one of {allowed_values}, got '{value}'")
293
+
294
+ elif dtype == int:
295
+ if not isinstance(value, int):
296
+ raise ValueError(
297
+ f"{param_name} must be an integer, got {type(value).__name__}{_format_constraints()}"
298
+ )
299
+ if min_val is not None and value < min_val:
300
+ raise ValueError(
301
+ f"{param_name} must be >= {min_val}, got {value}{_format_constraints()}"
302
+ )
303
+ if max_val is not None and value > max_val:
304
+ raise ValueError(
305
+ f"{param_name} must be <= {max_val}, got {value}{_format_constraints()}"
306
+ )
307
+ if allowed_values is not None and value not in allowed_values:
308
+ raise ValueError(f"{param_name} must be one of {allowed_values}, got {value}")
309
+
310
+ elif dtype == float:
311
+ if not isinstance(value, (int, float)):
312
+ raise ValueError(
313
+ f"{param_name} must be a number, got {type(value).__name__}{_format_constraints()}"
314
+ )
315
+ if min_val is not None and value < min_val:
316
+ raise ValueError(
317
+ f"{param_name} must be >= {min_val}, got {value}{_format_constraints()}"
318
+ )
319
+ if max_val is not None and value > max_val:
320
+ raise ValueError(
321
+ f"{param_name} must be <= {max_val}, got {value}{_format_constraints()}"
322
+ )
323
+ if allowed_values is not None and value not in allowed_values:
324
+ raise ValueError(f"{param_name} must be one of {allowed_values}, got {value}")
325
+
326
+ elif dtype == bool:
327
+ if not isinstance(value, bool):
328
+ raise ValueError(f"{param_name} must be a boolean, got {type(value).__name__}")
329
+
330
+ elif dtype == type:
331
+ if not (isinstance(value, type) or isinstance(value, np.dtype)):
332
+ raise ValueError(
333
+ f"{param_name} must be a (numpy) dtype, got {type(value).__name__}{_format_constraints()}"
334
+ )
335
+ if not issubclass(value, numbers.Number):
336
+ raise ValueError(
337
+ f"{param_name} must be a (numpy) dtype, got {type(value).__name__}{_format_constraints()}"
338
+ )
339
+ # No min/max/allowed values for dtypes
340
+ # Handle special validation cases
341
+ elif dtype == "special_size":
342
+ if value is not None:
343
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
344
+ raise ValueError(
345
+ f"{param_name} must be a list or tuple of length 2, got {type(value).__name__}"
346
+ + f"with length {len(value) if hasattr(value, '__len__') else 'unknown'}"
347
+ )
348
+
349
+ elif dtype == "special_normalisation_method":
350
+ if not isinstance(value, NormalisationMethod):
351
+ raise ValueError(
352
+ f"{param_name} must be a NormalisationMethod enum value, got {type(value).__name__}"
353
+ )
354
+
355
+ elif dtype == "special_normalisation":
356
+ if not isinstance(value, DotMap):
357
+ raise ValueError(f"{param_name} must be a DotMap, got {type(value).__name__}")
358
+
359
+ elif dtype == "special_asinh_scale":
360
+ if not isinstance(value, (list, tuple, int, float)):
361
+ raise ValueError(
362
+ f"{param_name} must be a number or list/tuple of 3 numbers > 0, got {type(value).__name__}"
363
+ )
364
+ if isinstance(value, (list, tuple)):
365
+ if len(value) != 3:
366
+ raise ValueError(
367
+ f"{param_name} if list/tuple, must have length 3, got length {len(value)}"
368
+ )
369
+ if not all(isinstance(x, (int, float)) for x in value):
370
+ raise ValueError(
371
+ f"{param_name} values must be numbers, got types: {[type(x).__name__ for x in value]}"
372
+ )
373
+ if not all(0 < x for x in value):
374
+ raise ValueError(f"{param_name} values must be > 0, got: {value}")
375
+ else:
376
+ # Single value
377
+ if not isinstance(value, (int, float)):
378
+ raise ValueError(
379
+ f"{param_name} must be a number > 0, got {type(value).__name__}"
380
+ )
381
+ if not (0 < value):
382
+ raise ValueError(f"{param_name} must > 0, got: {value}")
383
+
384
+ elif dtype == "special_asinh_clip":
385
+ if not isinstance(value, (list, tuple, int, float)):
386
+ raise ValueError(
387
+ f"{param_name} must be a number or list/tuple of 3 numbers in ]0,100.], got {type(value).__name__}"
388
+ )
389
+ if isinstance(value, (list, tuple)):
390
+ if len(value) != 3:
391
+ raise ValueError(
392
+ f"{param_name} if list/tuple, must have length 3, got length {len(value)}"
393
+ )
394
+ if not all(isinstance(x, (int, float)) for x in value):
395
+ raise ValueError(
396
+ f"{param_name} values must be numbers, got types: {[type(x).__name__ for x in value]}"
397
+ )
398
+ if not all(0 < x <= 100 for x in value):
399
+ raise ValueError(f"{param_name} values must be in range ]0,100.], got: {value}")
400
+ else:
401
+ # Single value
402
+ if not isinstance(value, (int, float)):
403
+ raise ValueError(
404
+ f"{param_name} must be a number in ]0,100.], got {type(value).__name__}"
405
+ )
406
+ if not (0 < value <= 100):
407
+ raise ValueError(f"{param_name} must be in range ]0,100.], got: {value}")
408
+
409
+ elif dtype == "special_crop":
410
+ if value is not None:
411
+ if not isinstance(value, (tuple, list)) or len(value) != 2:
412
+ raise ValueError(
413
+ f"{param_name} if set, must be a tuple of two integers, got {type(value).__name__}"
414
+ )
415
+ if not all(isinstance(x, int) for x in value):
416
+ raise ValueError(
417
+ f"{param_name} values must be integers, got types: {[type(x).__name__ for x in value]}"
418
+ )
419
+
420
+ elif dtype == "special_fits_extension":
421
+ if value is not None:
422
+ if isinstance(value, list):
423
+ # if no combination parameters are set
424
+ if len(value) != cfg.channel_combination.shape[1]:
425
+ raise ValueError(
426
+ f"{param_name} must be a str/int or list of strings/ints of length 1, n_output ="
427
+ + f"{cfg.n_output_channels}, or match channel_combination.shape[1] = "
428
+ + f"{cfg.channel_combination.shape[1]}, got list of length {len(value)}"
429
+ )
430
+
431
+ for v in value:
432
+ if not isinstance(v, (str, int)):
433
+ raise ValueError(
434
+ f"{param_name} list elements must be str or int, got {type(v).__name__}"
435
+ )
436
+ elif not isinstance(value, (str, int)):
437
+ raise ValueError(
438
+ f"{param_name} must be a str/int or list of strings/ints, got {type(value).__name__}"
439
+ )
440
+ elif dtype == "special_channel_combination":
441
+
442
+ if not isinstance(value, np.ndarray):
443
+ raise ValueError(f"{param_name} must be a numpyarray")
444
+ if np.any(value < 0):
445
+ raise ValueError(f"{param_name} must not have negative values")
446
+ # TODO check 2dimensionality
447
+ for i in range(0, value.shape[0]):
448
+ if np.any(np.allclose(np.sum(value[i, :]), 0)):
449
+ raise ValueError(
450
+ f"{param_name} values for channel '{i}' must not sum to zero, got {value[i, :]}"
451
+ )
452
+ if not value.shape[0] == cfg.n_output_channels and not value.shape[1] == len(
453
+ cfg.fits_extension
454
+ ):
455
+ raise ValueError(
456
+ f"{param_name} channel mapping shape must reflect input and output shapes:"
457
+ + f" expected n output {cfg.n_output_channels} & n input {len(cfg.fits_extension)}"
458
+ + f" got {value.shape[0]}x {value.shape[1]}"
459
+ )
460
+
461
+ else:
462
+ raise ValueError(f"Unknown data type for {param_name}: {dtype}")
463
+
464
+ # Custom cross-parameter validation
465
+ if "normalisation" in cfg:
466
+ if (
467
+ hasattr(cfg.normalisation, "maximum_value")
468
+ and hasattr(cfg.normalisation, "minimum_value")
469
+ and isinstance(cfg.normalisation.maximum_value, (int, float))
470
+ and isinstance(cfg.normalisation.minimum_value, (int, float))
471
+ ):
472
+ if cfg.normalisation.maximum_value <= cfg.normalisation.minimum_value:
473
+ raise ValueError(
474
+ f"normalisation.maximum_value {cfg.normalisation.maximum_value} must be larger than "
475
+ f"normalisation.minimum_value {cfg.normalisation.minimum_value}"
476
+ )
477
+
478
+ # Check for unexpected keys
479
+ actual_keys = _get_all_keys(cfg)
480
+ unexpected_keys = actual_keys - expected_keys
481
+
482
+ if unexpected_keys:
483
+ logger.warning(f"Found unexpected keys in config: {sorted(unexpected_keys)}")
484
+ logger.info("Config: validation partially successful")
485
+ else:
486
+ logger.info("Config: validation successful")