def validate_config(cfg: DotMap, check_paths: bool = True) -> None:
"""Validate configuration against required and optional keys specification.
Args:
cfg: Configuration to validate
check_paths: Whether to check if file and directory paths exist
Raises:
ValueError: If configuration is invalid
"""
# Get configuration specification
config_spec = _return_required_and_optional_keys()
# Keep track of checked keys
expected_keys = set()
# For relative directory paths, select base
current_file = os.path.abspath(__file__)
script_dir = os.path.abspath(os.path.join(current_file, "..", "..", "..", ".."))
# Validate each parameter
for param_name, (dtype, min_val, max_val, optional, allowed_values) in config_spec.items():
expected_keys.add(param_name)
# Try to get the value, handle missing optional parameters
try:
value = _get_nested_value(cfg, param_name)
except ValueError:
if optional:
continue # Skip missing optional parameters
else:
raise ValueError(
f"Missing required parameter: {param_name}"
+ f"(type: {dtype.__name__ if hasattr(dtype, '__name__') else dtype})"
)
# Skip validation for None values on optional parameters
if value is None and optional:
continue
# Helper function to format constraint info
def _format_constraints():
constraints = []
if min_val is not None:
constraints.append(f"min: {min_val}")
if max_val is not None:
constraints.append(f"max: {max_val}")
if allowed_values is not None:
constraints.append(f"allowed: {allowed_values}")
return f" ({', '.join(constraints)})" if constraints else ""
# Validate based on data type
if dtype is str:
if not isinstance(value, str):
raise ValueError(
f"{param_name} must be a string, got {type(value).__name__}{_format_constraints()}"
)
# Check allowed values for string types
if allowed_values is not None and value not in allowed_values:
raise ValueError(f"{param_name} must be one of {allowed_values}, got '{value}'")
elif dtype == "directory":
if not isinstance(value, str):
raise ValueError(
f"{param_name} must be a string/directory, got {type(value).__name__}"
)
if (
check_paths
and not os.path.isdir(value)
and not os.path.isdir(os.path.join(script_dir, value))
):
raise ValueError(
f"{param_name} directory does not exist: {value} or {os.path.join(script_dir, value)}"
)
elif dtype == "path_or_directory":
if not isinstance(value, str):
raise ValueError(f"{param_name} must be a string path, got {type(value).__name__}")
if check_paths and not (
os.path.exists(value) or os.path.exists(os.path.join(script_dir, value))
):
raise ValueError(
f"{param_name} path does not exist: {value} or {os.path.join(script_dir, value)}"
)
elif dtype == "file":
if not isinstance(value, str):
raise ValueError(
f"{param_name} must be a string/file path, got {type(value).__name__}"
)
if check_paths and not os.path.isfile(value):
raise ValueError(f"{param_name} file does not exist: {value}")
elif dtype is int:
if not isinstance(value, int):
raise ValueError(
f"{param_name} must be an integer, got {type(value).__name__}{_format_constraints()}"
)
if min_val is not None and value < min_val:
raise ValueError(
f"{param_name} must be >= {min_val}, got {value}{_format_constraints()}"
)
if max_val is not None and value > max_val:
raise ValueError(
f"{param_name} must be <= {max_val}, got {value}{_format_constraints()}"
)
if allowed_values is not None and value not in allowed_values:
raise ValueError(f"{param_name} must be one of {allowed_values}, got {value}")
elif dtype is float:
if not isinstance(value, (int, float)):
raise ValueError(
f"{param_name} must be a number, got {type(value).__name__}{_format_constraints()}"
)
if min_val is not None and value < min_val:
raise ValueError(
f"{param_name} must be >= {min_val}, got {value}{_format_constraints()}"
)
if max_val is not None and value > max_val:
raise ValueError(
f"{param_name} must be <= {max_val}, got {value}{_format_constraints()}"
)
if allowed_values is not None and value not in allowed_values:
raise ValueError(f"{param_name} must be one of {allowed_values}, got {value}")
elif dtype is bool:
if not isinstance(value, bool):
raise ValueError(f"{param_name} must be a boolean, got {type(value).__name__}")
# Handle special validation cases
elif dtype == "special_size":
if value is not None:
if not isinstance(value, (list, tuple)) or len(value) != 2:
raise ValueError(
f"{param_name} must be a list or tuple of length 2, got {type(value).__name__}"
+ f"with length {len(value) if hasattr(value, '__len__') else 'unknown'}"
)
elif dtype == "special_eval_iter":
if not isinstance(value, int) or (value != -1 and value <= 0):
raise ValueError(
f"{param_name} must be an integer > 0 or -1, got {value} (type: {type(value).__name__})"
)
elif dtype == "special_fitsbolt":
# The fitsbolt DotMap will be validated by fb_create_cfg
if not isinstance(value, DotMap):
raise ValueError(f"{param_name} must be a DotMap, got {type(value).__name__}")
else:
raise ValueError(f"Unknown data type for {param_name}: {dtype}")
# Validate normalisation parameter bounds
if hasattr(cfg, "normalisation"):
norm_errors = validate_normalisation(cfg.normalisation)
if norm_errors:
raise ValueError(
"Normalisation parameter validation failed:\n" + "\n".join(norm_errors)
)
# Also validate normalisation configuration with its own validation function if possible
if hasattr(cfg, "normalisation"):
cc = cfg.normalisation.channel_combination
_validate_channel_combination(cc)
# Coerce nested sequences up front so everything below — the rescaling, the
# n_output_channels inference — behaves the same whether the user wrote a list
# or an ndarray.
if isinstance(cc, (list, tuple)):
cc = np.asarray(cc)
cfg.normalisation.channel_combination = cc
# Keep channel_combination loss-free: a row of non-negative weights
# summing to >1 would push bright pixels past the uint8 range and be
# silently clipped by fitsbolt (Lasloruhberg/fitsbolt#40). Rescale such
# rows to sum 1 once here so every downstream combine site (training,
# prediction, Cutana/Zarr/image) shares the corrected matrix.
if isinstance(cc, np.ndarray):
normalised_cc, rescaled_rows, has_negative = normalise_channel_combination(cc)
if rescaled_rows:
logger.warning(
"channel_combination row(s) {} have non-negative weights summing to >1; "
"rescaling each to sum 1 so no signal is clipped on uint8 output. Use "
"convex weights (non-negative, row sum <= 1) to silence this.",
rescaled_rows,
)
cfg.normalisation.channel_combination = normalised_cc
cc = normalised_cc
if has_negative:
logger.warning(
"channel_combination contains negative weights; combined values may fall "
"below 0 and be clipped to 0 on uint8 output."
)
fits_ext = cfg.normalisation.fits_extension
# Infer n_output_channels from channel_combination matrix if provided
# isinstance rather than hasattr(cc, "shape"): probing an attribute on a
# dynamic DotMap creates it, so the empty-DotMap form of "no matrix"
# (what DotMap.copy() makes of None) would gain a bogus
# channel_combination.shape key.
if isinstance(cc, np.ndarray) and cc.ndim == 2:
inferred = cc.shape[0]
if inferred != cfg.normalisation.n_output_channels:
logger.info(
f"Setting n_output_channels to {inferred} "
f"from channel_combination shape {cc.shape}"
)
cfg.normalisation.n_output_channels = inferred
# Auto-create identity channel_combination for multiple FITS extensions
# when no explicit matrix is provided.
elif fits_ext is not None and isinstance(fits_ext, (list, tuple)) and len(fits_ext) > 1:
n_ext = len(fits_ext)
cfg.normalisation.channel_combination = np.eye(n_ext)
cfg.normalisation.n_output_channels = n_ext
logger.info(
f"Auto-created {n_ext}x{n_ext} identity channel_combination "
f"for {n_ext} FITS extensions (n_output_channels set to {n_ext})"
)
# Guard against n_output_channels being None (e.g. user unset it
# expecting auto-inference but only has a single FITS extension).
if cfg.normalisation.n_output_channels is None:
raise ValueError(
"n_output_channels is None and could not be inferred. "
"Set normalisation.n_output_channels explicitly or provide "
"multiple fits_extension entries or a channel_combination matrix."
)
# Keep cfg.num_channels in sync with n_output_channels
cfg.num_channels = cfg.normalisation.n_output_channels
# Ensure per-channel normalisation lists match n_output_channels
n_out = cfg.normalisation.n_output_channels
for attr in ("norm_asinh_scale", "norm_asinh_clip"):
val = cfg.normalisation[attr]
if isinstance(val, list) and len(val) != n_out and len(val) != 1:
if n_out < len(val):
cfg.normalisation[attr] = val[:n_out]
else:
cfg.normalisation[attr] = val + [val[-1]] * (n_out - len(val))
try:
# Validate exactly the matrix get_fitsbolt_config will hand fitsbolt, so a
# config passes here iff the run accepts it: blank rows arrive substituted
# (#588), and non-FITS sources hand over None because AnomalyMatch applies
# the matrix itself — fitsbolt would otherwise reject the negative weights
# that are legal on that path. _validate_channel_combination above covers
# the structure fitsbolt then never sees.
_ = fb_create_cfg(
output_dtype=cfg.normalisation.output_dtype,
size=cfg.normalisation.image_size,
fits_extension=cfg.normalisation.fits_extension,
interpolation_order=cfg.normalisation.interpolation_order,
n_output_channels=cfg.normalisation.n_output_channels,
normalisation_method=cfg.normalisation.normalisation_method,
channel_combination=fitsbolt_channel_combination(cfg),
num_workers=max(cfg.num_workers, 1),
norm_maximum_value=cfg.normalisation.norm_maximum_value,
norm_minimum_value=cfg.normalisation.norm_minimum_value,
norm_log_calculate_minimum_value=cfg.normalisation.norm_log_calculate_minimum_value,
norm_crop_for_maximum_value=cfg.normalisation.norm_crop_for_maximum_value,
norm_asinh_scale=cfg.normalisation.norm_asinh_scale,
norm_asinh_clip=cfg.normalisation.norm_asinh_clip,
norm_asinh_n_samples=cfg.normalisation.norm_asinh_n_samples,
)
logger.debug("fitsbolt configuration validated successfully")
# add the fitsbolt keys to expected keys used in above function call to expected_keys
expected_keys.update(
[
"normalisation.output_dtype",
"normalisation.image_size",
"normalisation.n_output_channels",
"normalisation.fits_extension",
"normalisation.interpolation_order",
"normalisation.normalisation_method",
"normalisation.channel_combination",
"normalisation.norm_maximum_value",
"normalisation.norm_minimum_value",
"normalisation.norm_log_calculate_minimum_value",
"normalisation.norm_crop_for_maximum_value",
"normalisation.norm_asinh_scale",
"normalisation.norm_asinh_clip",
"normalisation.norm_asinh_n_samples",
]
)
except Exception as e:
logger.error(f"normalisation configuration validation failed: {e}")
raise ValueError(f"normalisation configuration validation failed: {e}")
# fitsbolt_cfg is a nested DotMap created by fitsbolt's create_config()
actual_keys = _get_all_keys(cfg)
# Exclude the entire fitsbolt_cfg subtree — it's managed by fitsbolt,
# not by our config schema.
unexpected_keys = {k for k in (actual_keys - expected_keys) if not k.startswith("fitsbolt_cfg")}
if unexpected_keys:
logger.warning(f"Found unexpected keys in config: {sorted(unexpected_keys)}")
logger.info("Config: validation partially successful")
else:
logger.info("Config: validation successful")