File size: 6,479 Bytes
ae853c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""Science Lab Mode — a BrAPI-inspired germplasm/speciation surface over the
session catalog. Pure functions: build germplasm records, facets, a Newick
pedigree export, and a Monte-Carlo cross predictor from the real genetics.
"""
import colorsys
from collections import defaultdict

from breeder import _recombine, _avg_stabilities
from phenotype_pipeline import generate_phenotype

# Trait ontology (BrAPI ObservationVariable = Trait + Method + Scale).
TRAIT_VARIABLES = [
    {"key": "THC", "label": "THC", "unit": "%", "min": 0, "max": 35, "scale": "numeric"},
    {"key": "CBD", "label": "CBD", "unit": "%", "min": 0, "max": 20, "scale": "numeric"},
    {"key": "Yield", "label": "Yield", "unit": "g", "min": 30, "max": 140, "scale": "numeric"},
    {"key": "GrowTime", "label": "Grow Time", "unit": "d", "min": 40, "max": 80, "scale": "numeric"},
    {"key": "BudHue", "label": "Bud Hue", "unit": "deg", "min": 0, "max": 360, "scale": "circular"},
    {"key": "LeafHue", "label": "Leaf Hue", "unit": "deg", "min": 0, "max": 360, "scale": "circular"},
    {"key": "Stability", "label": "Stability", "unit": "", "min": 0, "max": 1, "scale": "numeric"},
]


def _hue(rgb):
    return round(colorsys.rgb_to_hsv(*[c / 255.0 for c in rgb])[0] * 360, 1)


def color_family(hue):
    if hue < 15 or hue >= 330:
        return "red"
    if hue < 40:
        return "orange"
    if hue < 70:
        return "yellow"
    if hue < 160:
        return "green"
    if hue < 200:
        return "cyan"
    if hue < 260:
        return "blue"
    if hue < 290:
        return "purple"
    return "magenta"


def _generation(seed, by_id, cache):
    if seed.seed_id in cache:
        return cache[seed.seed_id]
    p1, p2 = (seed.lineage or (None, None))
    gens = [_generation(by_id[p], by_id, cache) for p in (p1, p2) if p in by_id]
    gen = (max(gens) + 1) if gens else 0
    cache[seed.seed_id] = gen
    return gen


def _pedigree_str(seed, by_id):
    p1, p2 = (seed.lineage or (None, None))
    n1 = by_id[p1].strain_name if p1 in by_id else None
    n2 = by_id[p2].strain_name if p2 in by_id else None
    if n1 and n2:
        return f"{n1} / {n2}"
    if n1:
        return f"{n1} (clone)"
    return "founder"


def _mean_stability(seed):
    vals = [v for v in seed.stabilities.values() if isinstance(v, (int, float))]
    return round(sum(vals) / len(vals), 3) if vals else 0.0


def germplasm_records(seeds):
    by_id = {s.seed_id: s for s in seeds}
    cache = {}
    records = []
    for s in seeds:
        bud_hue, leaf_hue = _hue(s.bud_color), _hue(s.leaf_color)
        records.append({
            "germplasmDbId": s.seed_id,
            "germplasmName": s.strain_name,
            "germplasmType": s.type,
            "generation": _generation(s, by_id, cache),
            "pedigree": _pedigree_str(s, by_id),
            "parents": list(s.lineage or (None, None)),
            "isStarter": s.is_starter,
            "stage": s.growth_stage.name,
            "THC": s.thc,
            "CBD": s.cbd,
            "Yield": s.yield_,
            "GrowTime": s.grow_time,
            "BudHue": bud_hue,
            "LeafHue": leaf_hue,
            "Stability": _mean_stability(s),
            "budColor": list(s.bud_color),
            "leafColor": list(s.leaf_color),
            "budFamily": color_family(bud_hue),
            "alleles": s.alleles,
            "attemptsUsed": s.attempts_used,
            "maxAttempts": s.max_attempts,
            "canAttempt": s.can_attempt(),
        })
    return records


def facets(records):
    def count(key):
        d = defaultdict(int)
        for r in records:
            d[str(r[key])] += 1
        return dict(sorted(d.items()))

    return {
        "germplasmType": count("germplasmType"),
        "generation": count("generation"),
        "stage": count("stage"),
        "budFamily": count("budFamily"),
    }


def lab_payload(seeds):
    records = germplasm_records(seeds)
    return {
        "variables": TRAIT_VARIABLES,
        "records": records,
        "facets": facets(records),
        "count": len(records),
    }


def predict_cross(parent1, parent2, n=200):
    """Monte-Carlo the real breeding rules N times (no state mutation) and
    return predicted offspring trait distributions + a progeny color cloud."""
    thc, cbd, yld, grow = [], [], [], []
    bud_pts, leaf_pts = [], []
    for _ in range(n):
        alleles = _recombine(parent1.alleles, parent2.alleles)
        stab = _avg_stabilities(parent1.stabilities, parent2.stabilities)
        ph = generate_phenotype(alleles, stab)
        thc.append(round(ph["thc"], 2))
        cbd.append(round(ph["cbd"], 2))
        yld.append(round(ph["yield"], 1))
        grow.append(round(ph["growtime"], 1))
        bud_pts.append(ph["budcolor_rgb"])
        leaf_pts.append(ph["leafcolor_rgb"])

    def summary(arr):
        s = sorted(arr)
        return {
            "min": s[0], "max": s[-1],
            "mean": round(sum(s) / len(s), 2),
            "median": s[len(s) // 2],
            "values": arr,
        }

    return {
        "n": n,
        "parents": [parent1.strain_name, parent2.strain_name],
        "THC": summary(thc),
        "CBD": summary(cbd),
        "Yield": summary(yld),
        "GrowTime": summary(grow),
        "budCloud": bud_pts,
        "leafCloud": leaf_pts,
    }


def to_newick(seeds):
    """Reticulate pedigree as an Extended-Newick-style string (crosses appear
    under each parent; founders are leaves). Rooted at the newest specimens."""
    by_id = {s.seed_id: s for s in seeds}
    children = defaultdict(list)
    has_parent = set()
    for s in seeds:
        for p in (s.lineage or (None, None)):
            if p in by_id:
                children[p].append(s.seed_id)
                has_parent.add(s.seed_id)

    def safe(name):
        return name.replace(",", "_").replace("(", "[").replace(")", "]").replace(":", "-")

    seen = set()

    def render(node_id):
        node = by_id[node_id]
        kids = [k for k in children.get(node_id, []) if k not in seen]
        for k in kids:
            seen.add(k)
        label = f"{safe(node.strain_name)}#{node_id[:6]}"
        if not kids:
            return label
        inner = ",".join(render(k) for k in kids)
        return f"({inner}){label}"

    roots = [s.seed_id for s in seeds if s.seed_id not in has_parent]
    parts = [render(r) for r in roots if r not in seen or not seen.add(r)]
    return "(" + ",".join(parts) + ");"