import { colorFamily, hueFromRgb } from './color'; import { recombineProfiles } from './breeder'; import { bloomScoreFromPalette, generatePhenotype } from './phenotype'; import { canAttempt } from './state'; import type { BloomPredictionSummary, ColorPattern, CrossPrediction, DistributionSummary, GermplasmRecord, LabPayload, Rgb, SeedProfile, TraitVariable, } from './types'; export const TRAIT_VARIABLES: TraitVariable[] = [ { 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' }, { key: 'Volatility', label: 'Volatility', unit: '', min: 0, max: 1, scale: 'numeric' }, { key: 'Bloom', label: 'Bloom', unit: '', min: 0, max: 100, scale: 'numeric' }, ]; function generation(seed: SeedProfile, byId: Map, cache: Map): number { const cached = cache.get(seed.seedId); if (cached !== undefined) return cached; const parentGenerations = seed.lineage .map((parentId) => (parentId ? byId.get(parentId) : undefined)) .filter((parent): parent is SeedProfile => Boolean(parent)) .map((parent) => generation(parent, byId, cache)); const value = parentGenerations.length ? Math.max(...parentGenerations) + 1 : 0; cache.set(seed.seedId, value); return value; } function pedigree(seed: SeedProfile, byId: Map): string { const [parentOneId, parentTwoId] = seed.lineage; const parentOne = parentOneId ? byId.get(parentOneId) : undefined; const parentTwo = parentTwoId ? byId.get(parentTwoId) : undefined; if (parentOne && parentTwo) return `${parentOne.strainName} / ${parentTwo.strainName}`; if (parentOne) return `${parentOne.strainName} (clone)`; return 'founder'; } function meanStability(seed: SeedProfile): number { const values = Object.values(seed.stabilities).filter((value) => Number.isFinite(value)); return values.length ? Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 1000) / 1000 : 0; } export function germplasmRecords(seeds: SeedProfile[]): GermplasmRecord[] { const byId = new Map(seeds.map((seed) => [seed.seedId, seed])); const cache = new Map(); return seeds.map((seed) => { const budHue = hueFromRgb(seed.budColor); const leafHue = hueFromRgb(seed.leafColor); const stability = meanStability(seed); const budBloom = bloomScoreFromPalette(seed.budPalette, seed.budPattern); const leafBloom = bloomScoreFromPalette(seed.leafPalette, seed.leafPattern); return { germplasmDbId: seed.seedId, germplasmName: seed.strainName, germplasmType: seed.type, generation: generation(seed, byId, cache), pedigree: pedigree(seed, byId), parents: seed.lineage, isStarter: seed.isStarter, stage: seed.growthStage, THC: seed.thc, CBD: seed.cbd, Yield: seed.yield, GrowTime: seed.growTime, BudHue: budHue, LeafHue: leafHue, Stability: stability, Volatility: Math.round((1 - stability) * 1000) / 1000, Bloom: budBloom.score, budColor: seed.budColor, leafColor: seed.leafColor, budPalette: seed.budPalette, leafPalette: seed.leafPalette, budPattern: seed.budPattern, leafPattern: seed.leafPattern, budBloom, leafBloom, budFamily: colorFamily(budHue), alleles: seed.alleles, attemptsUsed: seed.attemptsUsed, maxAttempts: seed.maxAttempts, canAttempt: canAttempt(seed), }; }); } function countFacet(records: GermplasmRecord[], key: keyof GermplasmRecord): Record { const counts: Record = {}; for (const record of records) { const value = String(record[key]); counts[value] = (counts[value] ?? 0) + 1; } return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); } export function facets(records: GermplasmRecord[]): Record> { return { germplasmType: countFacet(records, 'germplasmType'), generation: countFacet(records, 'generation'), stage: countFacet(records, 'stage'), budFamily: countFacet(records, 'budFamily'), budPattern: countFacet(records, 'budPattern'), }; } export function labPayload(seeds: SeedProfile[]): LabPayload { const records = germplasmRecords(seeds); return { variables: TRAIT_VARIABLES, records, facets: facets(records), count: records.length, }; } function downsample(values: T[], cap: number): T[] { if (values.length <= cap) return values; const step = values.length / cap; const out: T[] = []; for (let index = 0; index < cap; index += 1) out.push(values[Math.floor(index * step)] as T); return out; } function summarize(values: number[], keepValues = true, cap = 80): DistributionSummary { const sorted = [...values].sort((left, right) => left - right); const total = sorted.reduce((sum, value) => sum + value, 0); return { min: sorted[0] ?? 0, max: sorted[sorted.length - 1] ?? 0, mean: sorted.length ? Math.round((total / sorted.length) * 100) / 100 : 0, median: sorted[Math.floor(sorted.length / 2)] ?? 0, values: keepValues ? downsample(values, cap) : [], }; } function incrementPattern(target: Record, pattern: ColorPattern): void { target[pattern] = (target[pattern] ?? 0) + 1; } function bloomHint(summary: { score: DistributionSummary; volatility: DistributionSummary; colorCount: DistributionSummary; maxColors: number; patterns: Record }): string { const topPattern = Object.entries(summary.patterns).sort((left, right) => right[1] - left[1])[0]?.[0] ?? 'solid'; if (summary.maxColors >= 5 || summary.score.max >= 82) return `wild bloom lane: up to ${summary.maxColors} colors, ${topPattern} dominant`; if (summary.colorCount.mean >= 3.2 || summary.score.mean >= 64) return `bloom-positive: ${topPattern} bias, mean ${summary.colorCount.mean.toFixed(1)} colors`; if (summary.volatility.mean >= 0.42) return `volatile but unproven: color spread needs a lucky roll`; return `stable expression: safer traits, lower bloom odds`; } function bloomSummary(scores: number[], volatilities: number[], colorCounts: number[], patterns: Record, keepValues = true): BloomPredictionSummary { const summary = { score: summarize(scores, keepValues), volatility: summarize(volatilities, keepValues), colorCount: summarize(colorCounts, keepValues), maxColors: Math.max(0, ...colorCounts), patterns, hint: '', }; return { ...summary, hint: bloomHint(summary) }; } export function predictCross(parentOne: SeedProfile, parentTwo: SeedProfile, sampleCount = 200, summaryOnly = false): CrossPrediction { const n = Math.max(20, Math.min(1000, sampleCount)); const thc: number[] = []; const cbd: number[] = []; const yieldValues: number[] = []; const growTime: number[] = []; const budCloud: Rgb[] = []; const leafCloud: Rgb[] = []; const budBloomScores: number[] = []; const leafBloomScores: number[] = []; const budColorCounts: number[] = []; const leafColorCounts: number[] = []; const budVolatility: number[] = []; const leafVolatility: number[] = []; const budPatterns: Record = { solid: 0, speckled: 0, split: 0, mosaic: 0, rimmed: 0, polycolor: 0 }; const leafPatterns: Record = { solid: 0, speckled: 0, split: 0, mosaic: 0, rimmed: 0, polycolor: 0 }; for (let index = 0; index < n; index += 1) { const { alleles, stabilities } = recombineProfiles(parentOne, parentTwo); const phenotype = generatePhenotype(alleles, stabilities); thc.push(Math.round(phenotype.thc * 100) / 100); cbd.push(Math.round(phenotype.cbd * 100) / 100); yieldValues.push(Math.round(phenotype.yield * 10) / 10); growTime.push(Math.round(phenotype.growTime * 10) / 10); const budBloom = bloomScoreFromPalette(phenotype.budPaletteRgb, phenotype.budPattern); const leafBloom = bloomScoreFromPalette(phenotype.leafPaletteRgb, phenotype.leafPattern); budCloud.push(phenotype.budColorRgb); leafCloud.push(phenotype.leafColorRgb); budBloomScores.push(budBloom.score); leafBloomScores.push(leafBloom.score); budColorCounts.push(budBloom.colorCount); leafColorCounts.push(leafBloom.colorCount); budVolatility.push(Math.round((1 - stabilities.BudColor) * 1000) / 1000); leafVolatility.push(Math.round((1 - stabilities.LeafColor) * 1000) / 1000); incrementPattern(budPatterns, phenotype.budPattern); incrementPattern(leafPatterns, phenotype.leafPattern); } const keepValues = !summaryOnly; return { n, parents: [parentOne.strainName, parentTwo.strainName], THC: summarize(thc, keepValues), CBD: summarize(cbd, keepValues), Yield: summarize(yieldValues, keepValues), GrowTime: summarize(growTime, keepValues), budCloud: summaryOnly ? [] : downsample(budCloud, 80), leafCloud: summaryOnly ? [] : downsample(leafCloud, 80), budBloom: bloomSummary(budBloomScores, budVolatility, budColorCounts, budPatterns, keepValues), leafBloom: bloomSummary(leafBloomScores, leafVolatility, leafColorCounts, leafPatterns, keepValues), }; } function safeLabel(name: string): string { return name.replaceAll(',', '_').replaceAll('(', '[').replaceAll(')', ']').replaceAll(':', '-'); } export function toNewick(seeds: SeedProfile[]): string { const byId = new Map(seeds.map((seed) => [seed.seedId, seed])); const children = new Map(); const hasParent = new Set(); for (const seed of seeds) { for (const parentId of seed.lineage) { if (parentId && byId.has(parentId)) { children.set(parentId, [...(children.get(parentId) ?? []), seed.seedId]); hasParent.add(seed.seedId); } } } const seen = new Set(); const render = (nodeId: string): string => { const node = byId.get(nodeId); if (!node) return ''; const kids = (children.get(nodeId) ?? []).filter((kid) => !seen.has(kid)); for (const kid of kids) seen.add(kid); const label = `${safeLabel(node.strainName)}#${node.seedId.slice(0, 6)}`; if (!kids.length) return label; return `(${kids.map(render).join(',')})${label}`; }; const roots = seeds.map((seed) => seed.seedId).filter((seedId) => !hasParent.has(seedId)); return `(${roots.filter((root) => !seen.has(root)).map(render).join(',')});`; }