| """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_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) + ");" |
|
|