Test and deploy / test-and-deploy (push) Successful in 1m32s
Adds a help-dialog UI (field-help.js/field-help-content.js) surfacing per-field guidance, expands cultivar and process reference data with cup notes, sweet-spot width, and watch-fors, and adds a Makefile for common npm tasks. Co-Authored-By: Claude Sonnet 5 <[email protected]>
247 lines
16 KiB
JavaScript
247 lines
16 KiB
JavaScript
// 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, Step 9 charge settings, Step 12 symptom table). Keep this the single
|
||
// source of truth — don't retype these numbers elsewhere.
|
||
|
||
import { parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||
|
||
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", tagline: "Fast to first crack, 7:30–8:00. Floral, creamy body, caramel, balanced." },
|
||
{ key: "bourbon", label: "Bourbon", fcAnchor: "8:30-9:30", tagline: "Slow to first crack, 8:30–9:30. Red fruit runs through all of them." },
|
||
{ key: "ethiopian", label: "Ethiopian", fcAnchor: "7:20-7:30", tagline: "Fastest, 7:20–7:30. Perfumed florality, sparkling citrus acidity." },
|
||
{ key: "hybrid", label: "Timor Hybrid", fcAnchor: "follows parent", tagline: "No group timing — follow whichever parent dominates the cross." },
|
||
];
|
||
|
||
// profile = [charge->yellow, yellow->FC, FC->drop], for a light roast. devModS is derived below.
|
||
// cup = "Expect in the cup"; watch = "Sweet spot & what to watch"; sweetSpot set only where the
|
||
// worksheet's watch column opens with a bolded Narrow/Wide call.
|
||
export const CULTIVARS = [
|
||
{ name: "Typica", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: "narrow",
|
||
cup: "Orange citrus, floral, caramel; medium body",
|
||
watch: "Slower → papery, starchy, thin. Faster than 7:20 → raw, aggressive acidity." },
|
||
{ name: "Maragogipe", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: "wide",
|
||
cup: "Tropical fruit, cherry, fruit punch, baking spice, creamy",
|
||
watch: "Huge bean → scorching, tipping, facing. Smaller batch + more airflow." },
|
||
{ name: "Pache", group: "typica", fcAnchor: "7:30-8:00", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: null,
|
||
cup: "Apple/pear or grape, floral, baking spice, milk chocolate",
|
||
watch: "Tolerates the slower end well. Don't go faster than 7:30." },
|
||
{ name: "Pacamara", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15", "3:15-3:45", "1:45"],
|
||
sweetSpot: null,
|
||
cup: "Sharp citrus acidity, baking spice, red fruit, nutty/earthy",
|
||
watch: "Needs the longest development here to tame acidity. Naturals 1:15–1:30. Big bean → same defect risks as Maragogipe." },
|
||
{ name: "SL-34", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: null,
|
||
cup: "Sweet citrus, berry/tropical, strong florality, very sweet",
|
||
watch: "Darkens easily — same profile lands ~5–10 Agtron points darker. Taper heat hard late. Watch for post-FC RoR crash." },
|
||
{ name: "Catuai", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15-5:00", "3:15-4:00", "1:30"],
|
||
sweetSpot: "wide",
|
||
cup: "Melon, citrus, syrupy, mild caramel, occasional red fruit",
|
||
watch: "Very wide — the forgiving one. Fast = Typica-like and floral; slow = Caturra-like with chocolate. Good coffee to learn on." },
|
||
|
||
{ name: "Bourbon", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"],
|
||
sweetSpot: null,
|
||
cup: "Deep cherry, caramel, almond, nutty, full body",
|
||
watch: "Some lots want a full 9:00; others read rough between 8:30–9:00. Start 8:45 and move by lot." },
|
||
{ name: "Caturra", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"],
|
||
sweetSpot: null,
|
||
cup: "Balanced caramel, mild acidity, red fruit, nutty",
|
||
watch: "Development is shorter than instinct suggests. Resist stretching it." },
|
||
{ name: "Pacas", group: "bourbon", fcAnchor: "8:45", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"],
|
||
sweetSpot: "narrow",
|
||
cup: "Red fruit, caramel, chocolate, nutty, rounded body",
|
||
watch: "Outside the band a distinct minerality shows up. Don't push it faster." },
|
||
{ name: "SL-28", group: "bourbon", fcAnchor: "8:00-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"],
|
||
sweetSpot: null,
|
||
cup: "Red currant, strawberry, passionfruit, floral, intense sweetness",
|
||
watch: "Belongs with Bourbon despite the Kenyan-fast reputation. A 5:00 yellow / 9:00 FC made it thin and starchy. Naturals do well pushed to 8:00 with 1:15 dev." },
|
||
|
||
{ name: "Ethiopian Heirloom", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: null,
|
||
cup: "Washed: citrus + caramel. Natural: strawberry/blueberry, syrupy, huge sweetness",
|
||
watch: "Catch-all label, so let the cup steer. Hollow/papery → walk FC out toward 8:00 then 9:00. Muddled/savory → speed up." },
|
||
{ name: "Gesha", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: "narrow",
|
||
cup: "Jasmine, crisp florals, citrus, sweet",
|
||
watch: "Too slow → savory, caramel-dominant, florals gone. Tipping-prone: keep inlet air cooler and use more airflow to go fast, not more heat." },
|
||
{ name: "Pink Bourbon", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:20"],
|
||
sweetSpot: "narrow",
|
||
cup: "Salt-water taffy / cotton candy sweetness, citrus, tropical, perfumed floral",
|
||
watch: "Not a Bourbon at all — genetically an Ethiopian landrace, so roast it fast. Even 8:00 turns it savory. Darkens quickly." },
|
||
{ name: "Sidra", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||
sweetSpot: "wide",
|
||
cup: "Peach, mango, tropical, floral, bright syrupy acidity",
|
||
watch: "Hard to ruin. Fast keeps florals vivid; 8:00 also works. Keep heat low through development or it darkens." },
|
||
{ name: "Papayo", group: "ethiopian", fcAnchor: "7:20", profile: ["4:20", "3:00", "1:20"],
|
||
sweetSpot: "narrow",
|
||
cup: "Mango, passionfruit, cinnamon, thick syrupy body",
|
||
watch: "First crack is very quiet — you may have to call it by smell. Darkens fast; if in doubt shorten development." },
|
||
|
||
{ name: "Castillo", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:00", "1:30"],
|
||
sweetSpot: null,
|
||
cup: "High citrus acidity, caramel, chocolate, hint of mint/bay or herbal",
|
||
watch: "Follows Caturra. Slower is the fix for herbaceousness — FC before 8:15 leaves it in; 9:00 removes it. Darker/espresso: stretch development further to mute acidity." },
|
||
{ name: "Tabi", group: "hybrid", fcAnchor: "8:00", profile: ["4:15", "3:45", "1:30"],
|
||
sweetSpot: "narrow",
|
||
cup: "Orange, melon, tropical/deep fruit, botanical, baking spice, perfumed floral",
|
||
watch: "Centred on 8:00. Slower → savory + herbaceous; faster → papery and thin." },
|
||
{ name: "H1 / Centroamericano", group: "hybrid", fcAnchor: "7:00", profile: ["4:15", "2:45", "1:30"],
|
||
sweetSpot: null,
|
||
cup: "Red fruit / pomegranate / passionfruit, cedar-like woody, nutty, caramel",
|
||
watch: "The Rume Sudan parent wins: fastest in the whole set. Speed is what suppresses the savory/herbaceous notes — the opposite of Castillo." },
|
||
{ name: "Catimor", group: "hybrid", fcAnchor: "8:30", profile: ["4:45", "4:15", "1:30-2:00"],
|
||
sweetSpot: null,
|
||
cup: "Sharp acidity, herbal/woody/vegetal, caramel, red fruit",
|
||
watch: "Caturra parentage → slow. Before 8:30 expect papery and dry. Naturals and honeys are far better than washed." },
|
||
{ name: "Sarchimor", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:15", "1:30-2:00"],
|
||
sweetSpot: null,
|
||
cup: "Pleasant acidity, caramel, mild herbal/botanical/woody",
|
||
watch: "Villa Sarchi parentage → slow, same as Catimor. Generally smoother; honey-processed lots are the pick." },
|
||
].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",
|
||
why: "Fermentation and washing strip the mucilage before drying, and the glucose and fructose are largely metabolised away during the first day on the beds. Less free sugar → slower browning → you can afford more time and heat. Cleanest, most acid-forward, most citrus-clear cup." },
|
||
{ key: "yellow-honey", label: "Yellow / white honey", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline",
|
||
why: "Little mucilage left; behaves essentially like a washed. Treat it as one." },
|
||
{ key: "red-black-honey", label: "Red / black honey", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4",
|
||
why: "Enough mucilage stays on to behave essentially like a natural. Treat it as one." },
|
||
{ key: "natural", label: "Natural", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4",
|
||
why: "The seed keeps far more free fructose and glucose, and fructose browns faster than sucrose does. Same profile, darker result. Shorten development and drop earlier or you overshoot the colour. Heaviest body, most overt fruit and ferment." },
|
||
{ key: "anaerobic", label: "Anaerobic / CM", devBand: "-0:10 to -0:15 further", devModS: -30, dropDeltaC: "lower still",
|
||
why: "Extended low-oxygen fermentation raises lactic acid, chlorogenic acids and trigonelline and drops glucose. These lots are volatile-rich and fragile. An anaerobic washed sits around 1:15–1:20, not 1:20–1:45." },
|
||
];
|
||
|
||
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",
|
||
character: "Almost without exception light-roast territory. Origin and cultivar character at maximum — every cultivar profile in the reference is written for this." },
|
||
{ key: "light-medium", label: "Light-medium", weightLossBand: "13-14%", devBand: "2:00",
|
||
character: "The transition. Acidity softening, sweetness filling in." },
|
||
{ key: "medium", label: "Medium", weightLossBand: "14-16%", devBand: "2:00-3:00",
|
||
character: "Less acidity, fewer delicate notes; caramel, nuts, chocolate take over." },
|
||
{ key: "dark", label: "Dark", weightLossBand: "17-18%", devBand: "3:00-4:00",
|
||
character: "Roast character becomes dominant: smoke, dark chocolate, woody." },
|
||
{ key: "very-dark", label: "Very dark", weightLossBand: "19-21%", devBand: "3:30-4:30",
|
||
character: "Roast notes dominate in ways most people find unpleasant." },
|
||
];
|
||
|
||
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." },
|
||
];
|
||
|
||
// Step 12 — full symptom -> cause -> fix table.
|
||
export const SYMPTOM_FIXES = [
|
||
{ symptom: "Papery, thin, starchy, hollow, dry",
|
||
cause: "Reached first crack too fast for this cultivar. Not enough time under pressure before the structure ruptured.",
|
||
change: "First crack +0:30",
|
||
note: "The classic symptom of running a Bourbon-group coffee on Typica timing." },
|
||
{ symptom: "Savory, muddled, vegetal, caramel-dominant, florals gone",
|
||
cause: "Reached first crack too slowly. Delicate aromatics were spent before you got there.",
|
||
change: "First crack −0:30",
|
||
note: "Exception: Castillo, Catimor and Sarchimor lose herbaceousness by going slower — move first crack the other way." },
|
||
{ symptom: "Sharp, astringent, acidity with no fruit behind it",
|
||
cause: "Development too short. Organic acids never got the time to resolve.",
|
||
change: "Development +0:15",
|
||
note: "Check DTR first — under 12% almost always confirms this." },
|
||
{ symptom: "Flat, dull, no acidity, uninteresting",
|
||
cause: "Development too long — you went past the peak of the bell curve.",
|
||
change: "Development −0:15",
|
||
note: "If development is already short, suspect a bake instead: look for a flat RoR stretch." },
|
||
{ symptom: "Baked — flat, papery, no sweetness, no clear defect",
|
||
cause: "Rate of rise flattened or crashed somewhere in the roast. The bean sat at temperature without progressing.",
|
||
change: "Fix the RoR shape",
|
||
note: "Smaller, earlier heat cuts. Nothing after the drop rescues a bake." },
|
||
{ symptom: "Darker than the weight loss predicts",
|
||
cause: "High-sugar coffee — SL cultivars, Sidra, Papayo, most naturals. More free fructose means faster browning.",
|
||
change: "Drop 3–5 °C earlier",
|
||
note: "Cut heat harder through development; re-baseline colour for this lot." },
|
||
{ symptom: "Sour and roasty at the same time",
|
||
cause: "Uneven roast — small beans finished while large ones lagged.",
|
||
change: "Smaller batch",
|
||
note: "Raise airflow too. Check the screen size distribution of the green." },
|
||
{ symptom: "Tipping — dark scorch at bean ends",
|
||
cause: "Inlet air temperature too high.",
|
||
change: "Lower charge, raise fan",
|
||
note: "Gesha is especially prone. More airflow at lower inlet keeps the speed without the damage." },
|
||
{ symptom: "Scorching / facing — flat burnt patches",
|
||
cause: "Drum surface too hot, or batch too large to keep beans moving.",
|
||
change: "Lower charge temp",
|
||
note: "Reduce batch size for large-bean cultivars — Maragogipe and Pacamara especially." },
|
||
{ symptom: "Blend tastes like only one component",
|
||
cause: "The dominant-component method is working as designed — you roasted for the majority share.",
|
||
change: "Re-weight the blend",
|
||
note: "If the minority component should show more, move to a weighted average, or split-roast and adjust the blend ratio after cupping each half separately." },
|
||
];
|
||
|
||
// Step 9 — charge probe & starting settings by batch, from the operator's own logs.
|
||
export const CHARGE_SETTINGS = [
|
||
{ batch: "200 g, fast target (FC 7:30–8:15)", chargeC: "133–163 °C", heat: "100%", fan: "30–50%",
|
||
observed: "Four logged roasts landed first crack 7:55–8:13 from here, all holding full heat ~3:00." },
|
||
{ batch: "200 g, standard (FC 8:45–9:30)", chargeC: "134–182 °C", heat: "89–100%", fan: "40–50%",
|
||
observed: "Your default; first reduction comes earlier, around 1:00–2:00." },
|
||
{ batch: "225 g (FC 9:30–11:15)", chargeC: "187–195 °C", heat: "92–100%", fan: "20–30%",
|
||
observed: "Larger mass pushes everything later — batch size is the strongest correlate with first-crack time in your data." },
|
||
];
|