""" Group size computation from PointOfImpact real-world coordinates. All measurements in millimetres. Origin is point-of-aim: x > 0 = right (windage), y > 0 = up (elevation). """ import math def compute_group_size( points: list[tuple[float, float]], distance_m: float | None = None, ) -> dict: """ Compute ballistic group metrics from a list of (x_mm, y_mm) coordinates. Args: points: list of (x_mm, y_mm) tuples — minimum 2 required. distance_m: shooting distance in metres, used for MOA conversion. Pass None to leave MOA fields as None. Returns: dict with keys matching GroupPhotoAnalysis fields. """ if len(points) < 2: raise ValueError("At least 2 points of impact are required.") xs = [p[0] for p in points] ys = [p[1] for p in points] n = len(points) # Extreme spread: maximum pairwise distance group_size_mm = 0.0 for i in range(n): for j in range(i + 1, n): d = math.sqrt((xs[i] - xs[j]) ** 2 + (ys[i] - ys[j]) ** 2) if d > group_size_mm: group_size_mm = d # Centroid cx = sum(xs) / n cy = sum(ys) / n # Mean radius: average distance from centroid mean_radius_mm = sum( math.sqrt((x - cx) ** 2 + (y - cy) ** 2) for x, y in points ) / n def to_moa(mm: float) -> float | None: """Convert mm at distance_m to MOA. 1 MOA ≈ 0.29089 mm/m at that distance.""" if distance_m is None or distance_m <= 0: return None return round(mm / (distance_m * 0.29089), 3) return { 'group_size_mm': round(group_size_mm, 2), 'group_size_moa': to_moa(group_size_mm), 'mean_radius_mm': round(mean_radius_mm, 2), 'mean_radius_moa': to_moa(mean_radius_mm), 'windage_offset_mm': round(cx, 2), 'windage_offset_moa': to_moa(cx), 'elevation_offset_mm': round(cy, 2), 'elevation_offset_moa': to_moa(cy), }