263 lines
13 KiB
Python
263 lines
13 KiB
Python
"""Generic, model-family-independent verification for direct CDSL revisions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any
|
|
|
|
|
|
QUALITY_RULE_TYPES = frozenset({
|
|
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
|
|
"hole_center", "overall_length", "overall_diameter", "through_condition",
|
|
})
|
|
_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"}
|
|
_SEVERITIES = {"blocking", "warning", "informational"}
|
|
|
|
|
|
def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None:
|
|
"""Return a whole-model or feature-owned runtime bounding box."""
|
|
candidates: list[Any] = []
|
|
if feature_id:
|
|
for record in engine_result.get("topology_records") or []:
|
|
if not isinstance(record, dict):
|
|
continue
|
|
owners = record.get("owner_feature_ids") or []
|
|
if record.get("feature_id") == feature_id or feature_id in owners:
|
|
geometry = record.get("geometry")
|
|
if isinstance(geometry, dict):
|
|
candidates.append(geometry.get("bbox_mm"))
|
|
else:
|
|
bbox = engine_result.get("bbox_mm")
|
|
if isinstance(bbox, dict):
|
|
candidates.append([
|
|
*(bbox.get("min") or []),
|
|
*(bbox.get("max") or []),
|
|
])
|
|
|
|
boxes = [
|
|
value for value in candidates
|
|
if isinstance(value, list) and len(value) == 6
|
|
and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value)
|
|
]
|
|
if not boxes:
|
|
return None
|
|
minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)]
|
|
maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)]
|
|
return [float(item) for item in minimum], [float(item) for item in maximum]
|
|
|
|
|
|
def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None:
|
|
bounds = _bbox_bounds(engine_result, feature_id)
|
|
if bounds is None:
|
|
return None
|
|
minimum, maximum = bounds
|
|
return {
|
|
"min": minimum,
|
|
"max": maximum,
|
|
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
|
|
}
|
|
|
|
|
|
def _dimensions(engine_result: dict[str, Any]) -> list[float] | None:
|
|
value = _bbox_value(engine_result)
|
|
return value["dimensions"] if value else None
|
|
|
|
|
|
def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None:
|
|
return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None)
|
|
|
|
|
|
def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None:
|
|
return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None)
|
|
|
|
|
|
def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
|
|
if profile.get("type") == "circle":
|
|
center, radius = profile.get("center"), profile.get("radius_mm")
|
|
if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)):
|
|
return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}]
|
|
if profile.get("type") != "analytic_contours":
|
|
return []
|
|
return [
|
|
{"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])}
|
|
for contour in profile.get("contours") or [] if isinstance(contour, dict)
|
|
for segment in contour.get("segments") or [] if isinstance(segment, dict)
|
|
and segment.get("type") == "circle"
|
|
and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2
|
|
and isinstance(segment.get("radius_mm"), (int, float))
|
|
]
|
|
|
|
|
|
def _finite_number(value: Any) -> bool:
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
|
|
|
|
|
|
def _validate_expected(kind: str, expected: Any, index: int) -> None:
|
|
scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"}
|
|
if kind in scalar_types and not _finite_number(expected):
|
|
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
|
|
if kind == "bbox":
|
|
valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected)
|
|
valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"})
|
|
if valid_ranges and set(expected) == {"min", "max"}:
|
|
valid_ranges = all(
|
|
isinstance(expected[key], list)
|
|
and len(expected[key]) == 3
|
|
and all(_finite_number(value) for value in expected[key])
|
|
for key in ("min", "max")
|
|
)
|
|
elif valid_ranges:
|
|
valid_ranges = all(_finite_number(expected[key]) for key in expected)
|
|
if not valid_dimensions and not valid_ranges:
|
|
raise ValueError(
|
|
f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], "
|
|
"{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}"
|
|
)
|
|
if kind == "hole_center" and (
|
|
not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected)
|
|
):
|
|
raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center")
|
|
if kind == "through_condition" and not isinstance(expected, bool):
|
|
raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition")
|
|
|
|
|
|
def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]:
|
|
if verification is None:
|
|
return []
|
|
if not isinstance(verification, dict) or set(verification) - {"rules"}:
|
|
raise ValueError("verification must be an object containing only rules")
|
|
rules = verification.get("rules", [])
|
|
if not isinstance(rules, list) or len(rules) > 32:
|
|
raise ValueError("verification.rules must be an array with at most 32 rules")
|
|
feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")}
|
|
normalized: list[dict[str, Any]] = []
|
|
ids: set[str] = set()
|
|
for index, raw in enumerate(rules):
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"verification.rules[{index}] must be an object")
|
|
allowed = {"id", "type", "feature", "expected", "tolerance", "severity"}
|
|
if set(raw) - allowed:
|
|
raise ValueError(f"verification.rules[{index}] has unsupported fields")
|
|
rule_id = str(raw.get("id") or "").strip()
|
|
kind = str(raw.get("type") or "").strip()
|
|
if not rule_id or rule_id in ids:
|
|
raise ValueError(f"verification.rules[{index}].id must be unique and non-empty")
|
|
if kind not in QUALITY_RULE_TYPES:
|
|
raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}")
|
|
if "expected" not in raw:
|
|
raise ValueError(f"verification.rules[{index}].expected is required")
|
|
_validate_expected(kind, raw["expected"], index)
|
|
severity = str(raw.get("severity") or "blocking")
|
|
if severity not in _SEVERITIES:
|
|
raise ValueError(f"verification.rules[{index}].severity is unsupported")
|
|
try:
|
|
tolerance = float(raw.get("tolerance") or 0.0)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error
|
|
if not math.isfinite(tolerance) or tolerance < 0:
|
|
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
|
|
feature = str(raw.get("feature") or "").strip()
|
|
if kind in _FEATURE_RULE_TYPES and not feature:
|
|
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
|
|
if feature and feature not in feature_ids:
|
|
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
|
|
ids.add(rule_id)
|
|
normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity})
|
|
return normalized
|
|
|
|
|
|
def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]:
|
|
kind, target = rule["type"], rule.get("feature") or None
|
|
feature = _feature(cdsl, target)
|
|
params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {}
|
|
sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or ""))
|
|
profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {}
|
|
circles = _circles(profile) if isinstance(profile, dict) else []
|
|
dimensions = _dimensions(engine_result)
|
|
if kind == "bbox":
|
|
value = _bbox_value(engine_result, target)
|
|
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
|
|
return value, source
|
|
if kind == "solid_count":
|
|
return float(engine_result.get("solid_count", 1)), "runtime.solid_count"
|
|
if kind == "feature_count":
|
|
return float(len(cdsl.get("features") or [])), "cdsl.features"
|
|
if kind == "hole_count":
|
|
return float(len(circles)), f"cdsl.features.{target}.sketch"
|
|
if kind == "hole_diameter":
|
|
value = params.get("diameter_mm", params.get("hole_diameter_mm"))
|
|
if isinstance(value, (int, float)):
|
|
return float(value), f"cdsl.features.{target}.params"
|
|
return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch"
|
|
if kind == "hole_center":
|
|
centers = [circle["center"] for circle in circles]
|
|
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
|
|
if kind == "overall_length":
|
|
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
|
|
if kind == "overall_diameter":
|
|
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
|
|
if kind == "through_condition":
|
|
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
|
|
if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}:
|
|
return True, f"cdsl.features.{target}.params.end_condition"
|
|
distance = params.get("distance_mm")
|
|
return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm"
|
|
return None, "unsupported verification type"
|
|
|
|
|
|
def _matches(expected: Any, actual: Any, tolerance: float) -> bool:
|
|
if actual is None:
|
|
return False
|
|
if isinstance(expected, list):
|
|
return isinstance(actual, list) and len(expected) == len(actual) and all(
|
|
_matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual)
|
|
)
|
|
if isinstance(expected, bool):
|
|
return bool(actual) is expected
|
|
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
|
|
return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0)
|
|
return expected == actual
|
|
|
|
|
|
def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool:
|
|
if actual is None:
|
|
return False
|
|
if isinstance(expected, list):
|
|
return _matches(expected, actual["dimensions"], tolerance)
|
|
if not isinstance(expected, dict):
|
|
return False
|
|
if set(expected) == {"min", "max"}:
|
|
return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance)
|
|
if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}:
|
|
actual_ranges = {
|
|
"x_min": actual["min"][0], "x_max": actual["max"][0],
|
|
"y_min": actual["min"][1], "y_max": actual["max"][1],
|
|
"z_min": actual["min"][2], "z_max": actual["max"][2],
|
|
}
|
|
return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges)
|
|
return False
|
|
|
|
|
|
def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]:
|
|
results = []
|
|
for rule in rules:
|
|
actual, source = _actual(rule, cdsl, engine_result)
|
|
passed = (
|
|
_matches_bbox(rule["expected"], actual, rule["tolerance"])
|
|
if rule["type"] == "bbox"
|
|
else _matches(rule["expected"], actual, rule["tolerance"])
|
|
)
|
|
results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source})
|
|
blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"]
|
|
warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"]
|
|
return {
|
|
"schema": "cad.quality-report.v1",
|
|
"schema_version": "1.0",
|
|
"status": "passed" if not blocking else "failed",
|
|
"verification_requested": bool(rules),
|
|
"results": results,
|
|
"blocking_failures": blocking,
|
|
"warnings": warnings,
|
|
"measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)},
|
|
}
|