from __future__ import annotations
"""Validation for QGIS project state and layer presence."""
from dataclasses import dataclass
from qgis.core import QgsRasterLayer, QgsVectorLayer, QgsWkbTypes
from .layer_validator import (
validate_field_exists,
validate_layer_geometry,
validate_layer_has_features,
validate_raster_band,
validate_structural_requirements,
)
from .path_validator import validate_output_path
from .validation_helpers import ValidationContext, validate_reasonable_ranges
# validate_reasonable_ranges moved to validation_helpers.py
[docs]
@dataclass
class ValidationParams:
"""Data container for all parameters that need cross-layer validation."""
raster_layer: QgsRasterLayer | None = None
band_number: int | None = None
line_layer: QgsVectorLayer | None = None
output_path: str = ""
scale: float = 1.0
vert_exag: float = 1.0
buffer_dist: float = 0.0
outcrop_layer: QgsVectorLayer | None = None
outcrop_field: str | None = None
struct_layer: QgsVectorLayer | None = None
struct_dip_field: str | None = None
struct_strike_field: str | None = None
dip_scale_factor: float = 1.0
# Drillhole params
collar_layer: QgsVectorLayer | None = None
collar_id: str | None = None
collar_use_geom: bool = True
collar_x: str | None = None
collar_y: str | None = None
survey_layer: QgsVectorLayer | None = None
survey_id: str | None = None
survey_depth: str | None = None
survey_azim: str | None = None
survey_incl: str | None = None
interval_layer: QgsVectorLayer | None = None
interval_id: str | None = None
interval_from: str | None = None
interval_to: str | None = None
interval_lith: str | None = None
[docs]
class ProjectValidator:
"""Orchestrates validation of project parameters independent of the GUI.
Level 2: Business Logic Validation.
Uses ValidationContext to accumulate errors and verify cross-field dependencies.
"""
[docs]
@staticmethod
def validate_all(params: ValidationParams) -> bool:
"""Perform a comprehensive validation of all project parameters.
Args:
params: The parameters to validate.
Returns:
True if all checks passed.
Raises:
ValidationError: If any validation check fails (contains list of errors).
"""
# Level 2 Validation: Business Context Accumulation
context = ValidationContext()
# Phase 1: Critical Structural Checks (Dependencies)
# Assuming DEM and Section are critical for any operation
ProjectValidator._validate_dem(params, context)
ProjectValidator._validate_section(params, context)
ProjectValidator._validate_output(params, context)
# Stop here if critical foundations are missing?
# For now, we continue to gather as much info as possible unless it crashes.
# Phase 2: Numeric Ranges & Business Rules
ProjectValidator._validate_numeric_ranges(params, context)
ProjectValidator._validate_geology(params, context)
ProjectValidator._validate_structural(params, context)
# Phase 3: Drillhole dependencies (Complex)
ProjectValidator._validate_drillholes(params, context)
# Raise accumulated errors
context.raise_if_errors()
return True
@staticmethod
def _validate_dem(params: ValidationParams, context: ValidationContext) -> None:
"""Validate Raster DEM layer."""
if not params.raster_layer:
context.add_error("Raster DEM layer is required", "raster_layer")
elif params.band_number is not None:
is_valid, error = validate_raster_band(params.raster_layer, params.band_number)
if not is_valid:
context.add_error(error, "band_number")
@staticmethod
def _validate_section(params: ValidationParams, context: ValidationContext) -> None:
"""Validate Cross-section line layer."""
if not params.line_layer:
context.add_error("Cross-section line layer is required", "line_layer")
return
is_valid, error = validate_layer_geometry(params.line_layer, QgsWkbTypes.LineGeometry)
if not is_valid:
context.add_error(error, "line_layer")
is_valid, error = validate_layer_has_features(params.line_layer)
if not is_valid:
context.add_error(error, "line_layer")
@staticmethod
def _validate_output(params: ValidationParams, context: ValidationContext) -> None:
"""Validate output path."""
if not params.output_path:
context.add_error("Output directory path is required", "output_path")
else:
is_valid, error, _ = validate_output_path(params.output_path)
if not is_valid:
context.add_error(error, "output_path")
@staticmethod
def _validate_numeric_ranges(params: ValidationParams, context: ValidationContext) -> None:
"""Validate numeric parameter ranges."""
# Level 1 logic might handle simple types, but business rules (Project Level) check context consistency here.
# Warnings are accumulated too.
warnings = validate_reasonable_ranges(
{
"vert_exag": params.vert_exag,
"scale": params.scale,
"buffer": params.buffer_dist,
"dip_scale": params.dip_scale_factor,
}
)
for warn in warnings:
context.add_warning(warn)
if params.scale < 1:
context.add_error("Scale must be >= 1", "scale")
if params.vert_exag < 0.1:
context.add_error("Vertical exaggeration must be >= 0.1", "vert_exag")
if params.buffer_dist < 0:
context.add_error("Buffer distance must be >= 0", "buffer_dist")
if params.dip_scale_factor < 0.1:
context.add_error("Dip scale factor must be >= 0.1", "dip_scale_factor")
@staticmethod
def _validate_geology(params: ValidationParams, context: ValidationContext) -> None:
"""Validate Geology Inputs using Dependency Rules."""
# Rule: If outcrop layer is present, field is required.
if params.outcrop_layer:
# Check geometry
is_valid, error = validate_layer_geometry(
params.outcrop_layer, QgsWkbTypes.PolygonGeometry
)
if not is_valid:
context.add_error(error, "outcrop_layer")
# Check features
is_valid, error = validate_layer_has_features(params.outcrop_layer)
if not is_valid:
context.add_error(error, "outcrop_layer")
# Check field dependency
if not params.outcrop_field:
context.add_error(
"Geology unit field is required when geology layer is selected",
"outcrop_field",
)
else:
is_valid, error = validate_field_exists(params.outcrop_layer, params.outcrop_field)
if not is_valid:
context.add_error(error, "outcrop_field")
@staticmethod
def _validate_structural(params: ValidationParams, context: ValidationContext) -> None:
"""Validate Structural Inputs."""
if params.struct_layer:
is_valid, error = validate_structural_requirements(
params.struct_layer,
params.struct_layer.name(),
params.struct_dip_field,
params.struct_strike_field,
)
if not is_valid:
context.add_error(error, "struct_layer")
@staticmethod
def _validate_drillholes(params: ValidationParams, context: ValidationContext) -> None:
"""Validate Drillhole Complex Dependencies (Level 2)."""
from sec_interp.core.validation.validation_helpers import (
DependencyRule,
validate_dependencies,
)
# Helper to simplify rules
has_collar = bool(params.collar_layer)
has_survey = bool(params.survey_layer)
has_interval = bool(params.interval_layer)
# Collar Rules
if has_collar:
if not params.collar_id:
context.add_error("Collar ID field is required", "collar_id")
if not params.collar_use_geom:
if not params.collar_x:
context.add_error(
"Collar X field is required (when not using geometry)",
"collar_x",
)
if not params.collar_y:
context.add_error(
"Collar Y field is required (when not using geometry)",
"collar_y",
)
# Survey Rules (Depend on Collar effectively for a valid project, but here we validate internal consistency)
if has_survey:
rules = [
DependencyRule(
lambda: True,
lambda: bool(params.survey_id),
"Survey ID field is required",
"survey_id",
),
DependencyRule(
lambda: True,
lambda: bool(params.survey_depth),
"Survey Depth field is required",
"survey_depth",
),
DependencyRule(
lambda: True,
lambda: bool(params.survey_azim),
"Survey Azimuth field is required",
"survey_azim",
),
DependencyRule(
lambda: True,
lambda: bool(params.survey_incl),
"Survey Inclination field is required",
"survey_incl",
),
]
validate_dependencies(rules, context)
# Interval Rules
if has_interval:
rules = [
DependencyRule(
lambda: True,
lambda: bool(params.interval_id),
"Interval ID field is required",
"interval_id",
),
DependencyRule(
lambda: True,
lambda: bool(params.interval_from),
"Interval From field is required",
"interval_from",
),
DependencyRule(
lambda: True,
lambda: bool(params.interval_to),
"Interval To field is required",
"interval_to",
),
DependencyRule(
lambda: True,
lambda: bool(params.interval_lith),
"Interval Lithology field is required",
"interval_lith",
),
]
validate_dependencies(rules, context)
[docs]
@staticmethod
def validate_preview_requirements(params: ValidationParams) -> bool:
"""Validate only the minimum requirements needed to generate a preview."""
context = ValidationContext()
if not params.raster_layer:
context.add_error("Raster DEM layer is required")
if not params.line_layer:
context.add_error("Cross-section line layer is required")
context.raise_if_errors()
return True
# Legacy helper methods for GUI checks (kept for backward compatibility with check_completeness methods)
# Ideally these should delegate to the context logic, but for simple booleans, direct checks are fine.
[docs]
@staticmethod
def is_drillhole_complete(params: ValidationParams) -> bool:
"""Check if required fields are filled if drillhole layers are selected."""
# Basic presence check
if not params.collar_layer or not params.collar_id:
return False
# Internal consistency check
context = ValidationContext()
ProjectValidator._validate_drillholes(params, context)
return not context.has_errors
[docs]
@staticmethod
def is_dem_complete(params: ValidationParams) -> bool:
if not params.raster_layer:
return False
context = ValidationContext()
ProjectValidator._validate_dem(params, context)
return not context.has_errors
[docs]
@staticmethod
def is_geology_complete(params: ValidationParams) -> bool:
if not params.outcrop_layer or not params.outcrop_field:
return False
context = ValidationContext()
ProjectValidator._validate_geology(params, context)
return not context.has_errors
[docs]
@staticmethod
def is_structure_complete(params: ValidationParams) -> bool:
if not params.struct_layer or not params.struct_dip_field or not params.struct_strike_field:
return False
context = ValidationContext()
ProjectValidator._validate_structural(params, context)
return not context.has_errors