weed-sim / lab.py
tostido's picture
WEED-SIM: evolutionary genetics sandbox with embedded Observer Bus
ae853c1
Raw
History Blame Contribute Delete
6.48 kB
"""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) + ");"