File size: 5,884 Bytes
a3054b6
 
d6218c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3054b6
 
d6218c1
 
 
 
 
 
a3054b6
d6218c1
 
 
a3054b6
d6218c1
 
a3054b6
 
 
 
 
 
 
d6218c1
a3054b6
 
 
 
 
 
 
d6218c1
 
a3054b6
d6218c1
a3054b6
 
 
d6218c1
a3054b6
 
 
 
 
 
 
 
 
 
 
 
d6218c1
a3054b6
5ec46b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""Ring size recommendation from calibrated finger width."""

from typing import Dict, List, Literal, Optional, Tuple

# Ring model definitions: model name → {size: inner_diameter_mm}
RING_MODELS: Dict[str, Dict[int, float]] = {
    "gen": {
        6: 16.9,
        7: 17.7,
        8: 18.6,
        9: 19.4,
        10: 20.3,
        11: 21.1,
        12: 21.9,
        13: 22.7,
    },
    "air": {
        6: 16.6,
        7: 17.4,
        8: 18.2,
        9: 19.0,
        10: 19.9,
        11: 20.7,
        12: 21.5,
        13: 22.3,
    },
}

VALID_RING_MODELS = list(RING_MODELS.keys())
DEFAULT_RING_MODEL = "gen"

# Backwards-compatible alias
RING_SIZE_CHART = RING_MODELS[DEFAULT_RING_MODEL]


def _get_sorted_sizes(ring_model: str) -> List[Tuple[int, float]]:
    chart = RING_MODELS.get(ring_model, RING_MODELS[DEFAULT_RING_MODEL])
    return sorted(chart.items(), key=lambda x: x[1])


def recommend_ring_size(diameter_cm: float, ring_model: str = DEFAULT_RING_MODEL) -> Optional[Dict]:
    """Recommend ring size from calibrated finger outer diameter.

    Returns dict with:
        - best_match: nearest ring size (int)
        - best_match_inner_mm: inner diameter of best match
        - range_min / range_max: recommended 2-size range
        - diameter_mm: input converted to mm
        - ring_model: which model chart was used
    Returns None if diameter is out of reasonable range.
    """
    diameter_mm = diameter_cm * 10.0

    if diameter_mm < 14.0 or diameter_mm > 26.0:
        return None

    sorted_sizes = _get_sorted_sizes(ring_model)

    # Find nearest size
    best_size, best_inner = min(sorted_sizes, key=lambda x: abs(x[1] - diameter_mm))

    # Find second nearest size
    second_size, second_inner = min(
        (s for s in sorted_sizes if s[0] != best_size),
        key=lambda x: abs(x[1] - diameter_mm),
    )

    range_min = min(best_size, second_size)
    range_max = max(best_size, second_size)

    return {
        "best_match": best_size,
        "best_match_inner_mm": best_inner,
        "range_min": range_min,
        "range_max": range_max,
        "diameter_mm": round(diameter_mm, 2),
        "ring_model": ring_model,
    }


def aggregate_ring_sizes(per_finger_results: Dict[str, Dict]) -> Dict:
    """Aggregate ring size recommendations from multiple fingers.

    Args:
        per_finger_results: Dict mapping finger name to measurement result dict.
            Each value must have keys:
                - "finger_outer_diameter_cm": float or None
                - "confidence": float
                - "ring_size": dict from recommend_ring_size() or None
                - "fail_reason": str or None

    Returns:
        Dict with:
            - overall_best_size: int (consensus size if one exists in all
              fingers' ranges, otherwise confidence-weighted best size)
            - overall_range_min: int (min of all per-finger range_min)
            - overall_range_max: int (max of all per-finger range_max)
            - fingers_measured: int (total attempted)
            - fingers_succeeded: int (with valid measurement)
            - per_finger: dict of per-finger details
            - fail_reason: str or None (only if ALL fingers failed)
    """
    fingers_measured = len(per_finger_results)

    # Build per_finger summary
    per_finger: Dict[str, Dict] = {}
    for name, result in per_finger_results.items():
        failed = result.get("fail_reason") is not None or result.get("ring_size") is None
        rs = result.get("ring_size")
        per_finger[name] = {
            "diameter_cm": result.get("finger_outer_diameter_cm"),
            "confidence": result.get("confidence", 0.0),
            "best_match": rs["best_match"] if rs else None,
            "range": [rs["range_min"], rs["range_max"]] if rs else None,
            "status": "failed" if failed else "ok",
            "fail_reason": result.get("fail_reason"),
        }

    # Filter to succeeded fingers
    succeeded = {
        name: info for name, info in per_finger.items() if info["status"] == "ok"
    }

    if not succeeded:
        return {
            "fail_reason": "all_fingers_failed",
            "fingers_measured": fingers_measured,
            "fingers_succeeded": 0,
            "per_finger": per_finger,
        }

    # Confidence-weighted voting for best size
    vote_tally: Dict[int, float] = {}
    for info in succeeded.values():
        size = info["best_match"]
        vote_tally[size] = vote_tally.get(size, 0.0) + info["confidence"]

    weighted_best_size = max(vote_tally, key=lambda s: vote_tally[s])

    # Intersection-first override: if a size falls in every finger's range, prefer it
    all_ranges = [set(range(info["range"][0], info["range"][1] + 1))
                  for info in succeeded.values()]
    consensus_sizes = set.intersection(*all_ranges) if all_ranges else set()

    if consensus_sizes:
        # Pick the consensus size closest to the confidence-weighted winner
        overall_best_size = min(consensus_sizes,
                                key=lambda s: abs(s - weighted_best_size))
    else:
        overall_best_size = weighted_best_size

    # Aggregate range
    overall_range_min = min(info["range"][0] for info in succeeded.values())
    overall_range_max = max(info["range"][1] for info in succeeded.values())

    # Ensure range covers best size
    if overall_best_size < overall_range_min:
        overall_range_min = overall_best_size
    if overall_best_size > overall_range_max:
        overall_range_max = overall_best_size

    return {
        "overall_best_size": overall_best_size,
        "overall_range_min": overall_range_min,
        "overall_range_max": overall_range_max,
        "fingers_measured": fingers_measured,
        "fingers_succeeded": len(succeeded),
        "per_finger": per_finger,
        "fail_reason": None,
    }