// Learned roaster behavior, aggregated from every finished roast the user has uploaded // (actual_roasts.parsed). Purely deterministic — medians and averages, no model involved — // so the same profile can ground the plan curve, the plan chat, and roast evaluations // without drift. Complements shared/learn.js, which learns pace from worksheet planActual // entries; this learns from real telemetry. const median = (values) => { const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); if (!sorted.length) return null; const mid = Math.floor(sorted.length / 2); const value = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; return Math.round(value * 10) / 10; }; function avgRor(curve, fromS, toS) { const pts = (curve ?? []).filter((p) => p.t >= fromS && p.t <= toS); if (pts.length < 2) return null; const first = pts[0]; const last = pts[pts.length - 1]; if (last.t <= first.t) return null; return ((last.bt - first.bt) / (last.t - first.t)) * 60; } /** * @param {object[]} parsedRoasts array of parseAlog() outputs (actual_roasts.parsed) * @returns compact profile of how this user's machine actually behaves, or {n: 0}. */ export function computeRoasterProfile(parsedRoasts) { const roasts = (parsedRoasts ?? []).filter((p) => p && Array.isArray(p.curve)); if (!roasts.length) return { n: 0 }; const milestone = (p, key) => p.milestones?.find((m) => m.key === key) ?? null; const collect = (fn) => roasts.map(fn); const chargeTemps = collect((p) => p.curve.find((pt) => pt.t >= 0)?.bt); const tpTimes = collect((p) => p.turningPoint?.timeS); const tpTemps = collect((p) => p.turningPoint?.tempC); const yellowTimes = collect((p) => milestone(p, "yellow")?.timeS); const yellowTemps = collect((p) => milestone(p, "yellow")?.tempC); const fcTimes = collect((p) => milestone(p, "fc")?.timeS); const fcTemps = collect((p) => milestone(p, "fc")?.tempC); const dropTimes = collect((p) => milestone(p, "drop")?.timeS); const dropTemps = collect((p) => milestone(p, "drop")?.tempC); const dtrs = collect((p) => p.derived?.dtrPct); const losses = collect((p) => p.roast?.weightLossPct); const dryingRor = []; const maillardRor = []; const developmentRor = []; for (const p of roasts) { const yellow = milestone(p, "yellow"); const fc = milestone(p, "fc"); const drop = milestone(p, "drop"); if (yellow) dryingRor.push(avgRor(p.curve, 60, yellow.timeS)); if (yellow && fc) maillardRor.push(avgRor(p.curve, yellow.timeS, fc.timeS)); if (fc && drop) developmentRor.push(avgRor(p.curve, fc.timeS, drop.timeS)); } return { n: roasts.length, medians: { chargeTempC: median(chargeTemps), // Turning-point time is the practical "thermal lag" of the machine: how long charged // energy takes to reverse the probe dip. Deep/late TPs mean slow heat response. turningPointS: median(tpTimes), turningPointTempC: median(tpTemps), yellowS: median(yellowTimes), yellowTempC: median(yellowTemps), firstCrackS: median(fcTimes), firstCrackTempC: median(fcTemps), dropS: median(dropTimes), dropTempC: median(dropTemps), dtrPct: median(dtrs), weightLossPct: median(losses), }, rorCPerMin: { drying: median(dryingRor), maillard: median(maillardRor), development: median(developmentRor), }, }; }