// Browser-safe. Ported from /Users/shane/dev/hope_roaster/roast-planner/manual-roast-planner.html // (Step 1 cultivar table, Step 3 processing table, Step 4 roast-level table, Step 8 machine bands, // Step 7 sanity bands). Keep this the single source of truth — don't retype these numbers elsewhere. import { parseRangeMidpoint } from "./time.js"; export const YELLOW_RATIO = 0.56; const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share export const GROUPS = [ { key: "typica", label: "Typica", fcAnchor: "7:30-8:00" }, { key: "bourbon", label: "Bourbon", fcAnchor: "8:30-9:30" }, { key: "ethiopian", label: "Ethiopian", fcAnchor: "7:20-7:30" }, { key: "hybrid", label: "Timor Hybrid", fcAnchor: "follows parent" }, ]; // profile = [charge->yellow, yellow->FC, FC->drop], for a light roast. devModS is derived below. export const CULTIVARS = [ { name: "Typica", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] }, { name: "Maragogipe", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] }, { name: "Pache", group: "typica", fcAnchor: "7:30-8:00", profile: ["4:15", "3:15", "1:30"] }, { name: "Pacamara", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15", "3:15-3:45", "1:45"] }, { name: "SL-34", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] }, { name: "Catuai", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15-5:00", "3:15-4:00", "1:30"] }, { name: "Bourbon", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] }, { name: "Caturra", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] }, { name: "Pacas", group: "bourbon", fcAnchor: "8:45", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] }, { name: "SL-28", group: "bourbon", fcAnchor: "8:00-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] }, { name: "Ethiopian Heirloom", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] }, { name: "Gesha", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] }, { name: "Pink Bourbon", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:20"] }, { name: "Sidra", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] }, { name: "Papayo", group: "ethiopian", fcAnchor: "7:20", profile: ["4:20", "3:00", "1:20"] }, { name: "Castillo", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:00", "1:30"] }, { name: "Tabi", group: "hybrid", fcAnchor: "8:00", profile: ["4:15", "3:45", "1:30"] }, { name: "H1 / Centroamericano", group: "hybrid", fcAnchor: "7:00", profile: ["4:15", "2:45", "1:30"] }, { name: "Catimor", group: "hybrid", fcAnchor: "8:30", profile: ["4:45", "4:15", "1:30-2:00"] }, { name: "Sarchimor", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:15", "1:30-2:00"] }, ].map((c) => ({ ...c, devModS: parseRangeMidpoint(c.profile[2]) - DEFAULT_DEV_THIRD_S, })); export function findCultivar(name) { if (!name) return null; const norm = String(name).trim().toLowerCase().replace(/["'.]/g, ""); return ( CULTIVARS.find((c) => c.name.toLowerCase().replace(/["'.]/g, "") === norm) ?? CULTIVARS.find((c) => norm.includes(c.name.toLowerCase().split(" / ")[0])) ?? null ); } export function findGroup(key) { return GROUPS.find((g) => g.key === key) ?? null; } export const PROCESSES = [ { key: "washed", label: "Washed", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" }, { key: "yellow-honey", label: "Yellow / white honey", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" }, { key: "red-black-honey", label: "Red / black honey", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" }, { key: "natural", label: "Natural", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" }, { key: "anaerobic", label: "Anaerobic / CM", devBand: "-0:10 to -0:15 further", devModS: -30, dropDeltaC: "lower still" }, ]; export function findProcess(key) { return PROCESSES.find((p) => p.key === key) ?? null; } export const ROAST_LEVELS = [ { key: "light", label: "Light", weightLossBand: "11-13%", devBand: "1:15-2:00" }, { key: "light-medium", label: "Light-medium", weightLossBand: "13-14%", devBand: "2:00" }, { key: "medium", label: "Medium", weightLossBand: "14-16%", devBand: "2:00-3:00" }, { key: "dark", label: "Dark", weightLossBand: "17-18%", devBand: "3:00-4:00" }, { key: "very-dark", label: "Very dark", weightLossBand: "19-21%", devBand: "3:30-4:30" }, ]; export function findRoastLevel(key) { return ROAST_LEVELS.find((r) => r.key === key) ?? null; } // Step 8 — from the operator's own 14 logged roasts. export const MACHINE = { charge: { medianC: 163, rangeC: [133, 195] }, turningPoint: { medianC: 85, medianTime: "0:52", rangeC: [77, 100], rangeTime: "0:47-1:15" }, yellow: { medianC: 158, rangeC: [148, 166] }, firstCrack: { medianC: 183, rangeC: [176, 187] }, drop: { medianC: 192, rangeC: [183, 197] }, ror: { tpToYellow: [17, 25], yellowToFc: [6, 10], fcToDrop: [3, 9], }, deadTimeS: 60, fanEffectCPerMinPer10Pct: -1.3, }; // Step 7 sanity bands. export const SANITY_BANDS = { drying: { lo: 43, hi: 51 }, maillard: { lo: 33, hi: 39 }, dtr: { lo: 12, hi: 20 }, ceilingS: 270, // 4:30 }; // Step 9 taper template — starting shape for box 9's actuator schedule. export const TAPER_TEMPLATE = [ { point: "Charge", heat: "90-100%", fan: "30-50%", intent: "Recover from the charge dip and drive to the turning point." }, { point: "Hold, then first cut", heat: "-> 90-95%", fan: "hold", intent: "When you make this cut is your main first-crack lever." }, { point: "Mid-drying", heat: "85-90%", fan: "50-60%", intent: "Begin the decline; steps of 3-5% beat single large ones." }, { point: "At yellow", heat: "77-82%", fan: "60-70%", intent: "Airflow rising as chaff releases; RoR around 8-10 C/min." }, { point: "Late Maillard", heat: "70-75%", fan: "70-80%", intent: "Get ahead of the exotherm here, not after it." }, { point: "At first crack", heat: "60-70%", fan: "70-80%", intent: "Cut before the exotherm, gently." }, { point: "Development", heat: "55-65%", fan: "80%", intent: "Small trims only." }, { point: "Drop", heat: "0%", fan: "100%", intent: "Cool fast; log the drop time." }, ];