weed-sim / src /shared /lab.ts
Yufok1
Checkpoint Devvit v0.0.26
616ae7b
Raw
History Blame Contribute Delete
11 kB
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(',')});`;
}