| """ |
| 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 |
| confidence: float |
| overall_score: float |
| 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 = { |
| |
| "max_cell_defect_score": 50.0, |
| "max_crack_length_mm": 30.0, |
| "max_dark_area_pct": 40.0, |
| "max_cracks_per_cell": 5, |
| |
| |
| "max_defective_cell_ratio": 0.15, |
| "max_total_crack_length_mm": 200.0, |
| "max_avg_defect_score": 25.0, |
| } |
| |
| 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 = [] |
| |
| |
| for result in cell_results: |
| cell_fails = [] |
| |
| |
| 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']}" |
| ) |
| |
| |
| 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" |
| ) |
| |
| |
| 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']}%" |
| ) |
| |
| |
| 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) |
| |
| |
| 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']}" |
| ) |
| |
| |
| decision = "FAIL" if failure_reasons else "PASS" |
| |
| |
| 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 = { |
| "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}") |
|
|