Validate semantics.py

From Open Source Ecology
Jump to navigation Jump to search
  1. !/usr/bin/env python3

import math import sys from pathlib import Path from typing import Any

import yaml


def load_yaml(path: Path) -> Any:

   with path.open("r", encoding="utf-8") as f:
       return yaml.safe_load(f)


def is_number(value: Any) -> bool:

   return isinstance(value, (int, float)) and not isinstance(value, bool)


def nominal_to_actual_lumber(nominal: str) -> tuple[float, float]:

   """
   Returns (actual_thickness_in, actual_depth_in)
   for nominal lumber like '2x4', '2x6', etc.
   """
   mapping = {
       "2x2": (1.5, 1.5),
       "2x3": (1.5, 2.5),
       "2x4": (1.5, 3.5),
       "2x6": (1.5, 5.5),
       "2x8": (1.5, 7.25),
       "2x10": (1.5, 9.25),
       "2x12": (1.5, 11.25),
   }
   if nominal not in mapping:
       raise ValueError(f"Unsupported nominal lumber size: {nominal}")
   return mapping[nominal]


def get_param(instance: dict[str, Any], name: str) -> Any:

   return instance.get("parameters", {}).get(name)


def validate_instance_semantics(instance: dict[str, Any]) -> list[str]:

   errors: list[str] = []
   instance_id = instance.get("id", "<unknown>")
   params = instance.get("parameters", {})
   if not isinstance(params, dict):
       return [f"{instance_id}: parameters must be a mapping"]
   width_ft = get_param(instance, "nominal_width_ft")
   height_ft = get_param(instance, "nominal_height_ft")
   stud_nominal = get_param(instance, "stud_lumber_nominal")
   spacing_oc_in = get_param(instance, "stud_spacing_oc_in")
   osb_thickness_in = get_param(instance, "osb_thickness_in")
   exterior_face = get_param(instance, "exterior_face")
   house_orientation = get_param(instance, "reference_house_orientation")
   required = {
       "nominal_width_ft": width_ft,
       "nominal_height_ft": height_ft,
       "stud_lumber_nominal": stud_nominal,
       "stud_spacing_oc_in": spacing_oc_in,
       "osb_thickness_in": osb_thickness_in,
       "exterior_face": exterior_face,
       "reference_house_orientation": house_orientation,
   }
   for key, value in required.items():
       if value is None:
           errors.append(f"{instance_id}: missing parameter '{key}'")
   if errors:
       return errors
   if not is_number(width_ft):
       errors.append(f"{instance_id}: nominal_width_ft must be numeric")
       return errors
   if not is_number(height_ft):
       errors.append(f"{instance_id}: nominal_height_ft must be numeric")
       return errors
   if not is_number(spacing_oc_in):
       errors.append(f"{instance_id}: stud_spacing_oc_in must be numeric")
       return errors
   if not is_number(osb_thickness_in):
       errors.append(f"{instance_id}: osb_thickness_in must be numeric")
       return errors
   width_in = float(width_ft) * 12.0
   height_in = float(height_ft) * 12.0
   spacing_oc_in = float(spacing_oc_in)
   try:
       stud_thickness_in, stud_depth_in = nominal_to_actual_lumber(str(stud_nominal))
   except ValueError as e:
       errors.append(f"{instance_id}: {e}")
       return errors
   # 1. Width must at least accommodate the extreme filler case:
   # two studs fastened together.
   min_filler_width_in = 3.25
   if width_in < min_filler_width_in:
       errors.append(
           f"{instance_id}: width {width_in:.3f} in is below minimum meaningful filler module width "
           f"of {min_filler_width_in:.3f} in"
       )
   # 2. Height must be positive and practically meaningful.
   if height_in <= 0:
       errors.append(f"{instance_id}: height must be greater than zero")
   # 3. Exterior face must be meaningful in context of house orientation.
   if house_orientation == "faces_south" and exterior_face not in {"north", "south", "east", "west"}:
       errors.append(
           f"{instance_id}: exterior_face must be one of north/south/east/west"
       )
   # 4. Compute a simple stud-layout model:
   # Assume one stud at each end; interior studs at requested OC where possible.
   #
   # Clear space available between the two end studs:
   clear_span_in = width_in - 2.0 * stud_thickness_in
   if clear_span_in < 0:
       errors.append(
           f"{instance_id}: width {width_in:.3f} in is less than two stud thicknesses "
           f"({2.0 * stud_thickness_in:.3f} in)"
       )
       return errors
   # 5. Distinguish filler modules from standard framed panels.
   #
   # If the width is too small to contain any interior stud bay, this is allowed,
   # but it is a filler module only.
   if clear_span_in < spacing_oc_in:
       # Valid filler/special module.
       is_filler_module = True
       stud_count = 2
       interior_stud_count = 0
   else:
       is_filler_module = False
       interior_stud_count = math.floor(clear_span_in / spacing_oc_in)
       stud_count = 2 + interior_stud_count
   # 6. Standard framed panel sanity: if width is not tiny, require at least one meaningful bay
   # when the geometry suggests it should support standard layout.
   #
   # This avoids nonsense wide-enough panels that somehow do not produce a sensible stud layout.
   if width_in >= (2.0 * stud_thickness_in + spacing_oc_in) and stud_count < 3:
       errors.append(
           f"{instance_id}: width {width_in:.3f} in should support at least one interior stud "
           f"at {spacing_oc_in:.1f} in OC, but derived stud count was {stud_count}"
       )
   # 7. Check derived overall thickness.
   overall_panel_thickness_in = stud_depth_in + float(osb_thickness_in)
   if overall_panel_thickness_in <= stud_depth_in:
       errors.append(
           f"{instance_id}: overall panel thickness computation is invalid"
       )
   # 8. Practical warning-level checks converted to semantic errors only where clearly nonsensical.
   # A very tall but ultra-narrow wall is likely not meaningful as a standard wall module,
   # but filler modules are allowed. Here we only reject widths that are between filler and
   # standard wall but still fail to produce a coherent framing layout.
   if not is_filler_module and stud_count < 2:
       errors.append(
           f"{instance_id}: derived stud count {stud_count} is not meaningful for a wall panel"
       )
   return errors


def main() -> int:

   if len(sys.argv) != 2:
       print("Usage: python3 validate_semantics.py <instances.yaml>")
       return 2
   instances_path = Path(sys.argv[1])
   if not instances_path.exists():
       print(f"Error: instances file not found: {instances_path}")
       return 2
   try:
       instances_doc = load_yaml(instances_path)
   except yaml.YAMLError as e:
       print(f"YAML parse error: {e}")
       return 2
   except Exception as e:
       print(f"File load error: {e}")
       return 2
   if not isinstance(instances_doc, dict):
       print("Instances file root must be a mapping")
       return 1
   if "instances" not in instances_doc:
       print("Instances file must contain top-level key 'instances'")
       return 1
   instances = instances_doc["instances"]
   if not isinstance(instances, list):
       print("Instances file 'instances' must be a list")
       return 1
   all_errors: list[str] = []
   for idx, instance in enumerate(instances):
       if not isinstance(instance, dict):
           all_errors.append(f"instances[{idx}] must be a mapping")
           continue
       all_errors.extend(validate_instance_semantics(instance))
   if all_errors:
       print("Semantic validation failed:")
       for err in all_errors:
           print(f"  - {err}")
       return 1
   print(f"Semantic validation passed: {len(instances)} instance(s) validated successfully.")
   return 0


if __name__ == "__main__":

   raise SystemExit(main())