| 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<string, SeedProfile>, cache: Map<string, number>): 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, SeedProfile>): 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<string, number>(); |
| 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<string, number> { |
| const counts: Record<string, number> = {}; |
| 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<string, Record<string, number>> { |
| 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<T>(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<ColorPattern, number>, pattern: ColorPattern): void { |
| target[pattern] = (target[pattern] ?? 0) + 1; |
| } |
|
|
| function bloomHint(summary: { score: DistributionSummary; volatility: DistributionSummary; colorCount: DistributionSummary; maxColors: number; patterns: Record<ColorPattern, number> }): 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<ColorPattern, number>, 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<ColorPattern, number> = { solid: 0, speckled: 0, split: 0, mosaic: 0, rimmed: 0, polycolor: 0 }; |
| const leafPatterns: Record<ColorPattern, number> = { 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<string, string[]>(); |
| const hasParent = new Set<string>(); |
|
|
| 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<string>(); |
| 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(',')});`; |
| } |
|
|
|
|
|
|