""" Decision Engine: PASS/FAIL Module-Level Assessment. Takes cell-level analysis results and produces a module-level decision. Decision logic: 1. Single-cell failure: any cell with defect_score > threshold → FAIL 2. Aggregate failure: too many cells with moderate defects → FAIL 3. Critical defects: any crack > 30mm or dark > 50% → immediate FAIL 4. Cumulative: total crack length across all cells exceeds limit → FAIL Thresholds are configurable for different quality standards: - IEC: Standard international electrotechnical commission thresholds - Strict: Premium quality (e.g., residential rooftop) - Lenient: Utility-scale where minor defects are acceptable Design decision: Conservative defaults (IEC standard). Better to flag a good module for review than to pass a defective one. """ from typing import List, Dict from dataclasses import dataclass from .crack_analysis import CellAnalysisResult @dataclass class ModuleDecision: """Module-level PASS/FAIL decision with supporting evidence.""" decision: str # "PASS" or "FAIL" confidence: float # 0.0 - 1.0 overall_score: float # 0-100 num_cells: int num_defective_cells: int failure_reasons: List[str] cell_results: List[dict] summary: Dict[str, float] def to_dict(self) -> dict: return { "decision": self.decision, "confidence": round(self.confidence, 2), "overall_score": round(self.overall_score, 1), "num_cells": self.num_cells, "num_defective_cells": self.num_defective_cells, "failure_reasons": self.failure_reasons, "summary": {k: round(v, 2) for k, v in self.summary.items()}, "cell_results": self.cell_results, } class DecisionEngine: """ Module-level PASS/FAIL decision engine. Configurable thresholds for different quality standards. """ # Default thresholds (IEC-aligned) DEFAULT_THRESHOLDS = { # Per-cell thresholds "max_cell_defect_score": 50.0, # Score above this → cell FAIL "max_crack_length_mm": 30.0, # Single crack > this → cell FAIL "max_dark_area_pct": 40.0, # Dark area > this → cell FAIL "max_cracks_per_cell": 5, # More cracks than this → cell FAIL # Module-level thresholds "max_defective_cell_ratio": 0.15, # >15% cells defective → module FAIL "max_total_crack_length_mm": 200.0, # Total across all cells "max_avg_defect_score": 25.0, # Average score across cells } STRICT_THRESHOLDS = { "max_cell_defect_score": 30.0, "max_crack_length_mm": 15.0, "max_dark_area_pct": 20.0, "max_cracks_per_cell": 3, "max_defective_cell_ratio": 0.05, "max_total_crack_length_mm": 100.0, "max_avg_defect_score": 15.0, } LENIENT_THRESHOLDS = { "max_cell_defect_score": 70.0, "max_crack_length_mm": 50.0, "max_dark_area_pct": 60.0, "max_cracks_per_cell": 8, "max_defective_cell_ratio": 0.30, "max_total_crack_length_mm": 500.0, "max_avg_defect_score": 40.0, } def __init__( self, thresholds: Dict[str, float] = None, standard: str = "default", ): """ Args: thresholds: Custom thresholds dict. Overrides standard. standard: 'default' (IEC), 'strict', or 'lenient' """ if thresholds is not None: self.thresholds = thresholds elif standard == "strict": self.thresholds = self.STRICT_THRESHOLDS.copy() elif standard == "lenient": self.thresholds = self.LENIENT_THRESHOLDS.copy() else: self.thresholds = self.DEFAULT_THRESHOLDS.copy() def decide(self, cell_results: List[CellAnalysisResult]) -> ModuleDecision: """ Make module-level PASS/FAIL decision. Args: cell_results: List of per-cell analysis results Returns: ModuleDecision with decision, confidence, and evidence """ if not cell_results: return ModuleDecision( decision="PASS", confidence=0.5, overall_score=0.0, num_cells=0, num_defective_cells=0, failure_reasons=["No cells analyzed"], cell_results=[], summary={}, ) num_cells = len(cell_results) failure_reasons = [] defective_cells = [] # Per-cell analysis for result in cell_results: cell_fails = [] # Check defect score if result.defect_score > self.thresholds["max_cell_defect_score"]: cell_fails.append( f"Cell {result.cell_id}: defect score " f"{result.defect_score:.1f} > {self.thresholds['max_cell_defect_score']}" ) # Check crack length if result.total_crack_length_mm > self.thresholds["max_crack_length_mm"]: cell_fails.append( f"Cell {result.cell_id}: crack length " f"{result.total_crack_length_mm:.1f}mm > {self.thresholds['max_crack_length_mm']}mm" ) # Check dark area if result.dark.dark_area_pct > self.thresholds["max_dark_area_pct"]: cell_fails.append( f"Cell {result.cell_id}: dark area " f"{result.dark.dark_area_pct:.1f}% > {self.thresholds['max_dark_area_pct']}%" ) # Check crack count total_cracks = result.num_cracks + result.num_cross_cracks if total_cracks > self.thresholds["max_cracks_per_cell"]: cell_fails.append( f"Cell {result.cell_id}: {total_cracks} cracks " f"> {self.thresholds['max_cracks_per_cell']}" ) if cell_fails: defective_cells.append(result.cell_id) failure_reasons.extend(cell_fails) # Module-level checks defective_ratio = len(defective_cells) / num_cells if defective_ratio > self.thresholds["max_defective_cell_ratio"]: failure_reasons.append( f"Module: {len(defective_cells)}/{num_cells} cells defective " f"({defective_ratio:.1%} > {self.thresholds['max_defective_cell_ratio']:.1%})" ) total_crack_length = sum(r.total_crack_length_mm for r in cell_results) if total_crack_length > self.thresholds["max_total_crack_length_mm"]: failure_reasons.append( f"Module: total crack length {total_crack_length:.1f}mm " f"> {self.thresholds['max_total_crack_length_mm']}mm" ) avg_score = sum(r.defect_score for r in cell_results) / num_cells if avg_score > self.thresholds["max_avg_defect_score"]: failure_reasons.append( f"Module: avg defect score {avg_score:.1f} " f"> {self.thresholds['max_avg_defect_score']}" ) # Final decision decision = "FAIL" if failure_reasons else "PASS" # Confidence: based on how far metrics are from thresholds overall_score = avg_score if decision == "FAIL": confidence = min(0.5 + avg_score / 200.0, 0.99) else: confidence = min(0.5 + (100 - avg_score) / 200.0, 0.99) # Summary statistics summary = { "avg_defect_score": avg_score, "max_defect_score": max(r.defect_score for r in cell_results), "total_crack_length_mm": total_crack_length, "avg_dark_area_pct": sum(r.dark.dark_area_pct for r in cell_results) / num_cells, "max_dark_area_pct": max(r.dark.dark_area_pct for r in cell_results), "defective_cell_ratio": defective_ratio, "total_cracks": sum(r.num_cracks for r in cell_results), "total_cross_cracks": sum(r.num_cross_cracks for r in cell_results), } return ModuleDecision( decision=decision, confidence=confidence, overall_score=overall_score, num_cells=num_cells, num_defective_cells=len(defective_cells), failure_reasons=failure_reasons, cell_results=[r.to_dict() for r in cell_results], summary=summary, ) def update_thresholds(self, **kwargs): """Update individual thresholds.""" for key, value in kwargs.items(): if key in self.thresholds: self.thresholds[key] = value else: raise ValueError(f"Unknown threshold: {key}")