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