import { DateRange, PlayArrow, Stop } from "@mui/icons-material"; import { Box, Button, DialogActions, DialogContent, FormControl, IconButton, LinearProgress, MenuItem, Select, TextField, Typography } from "@mui/material"; import { grey } from "@mui/material/colors"; import ResponsiveDialog from "common/ResponsiveDialog"; import { useMobile } from "hooks"; import { cloneDeep } from "lodash"; import { Bin } from "models"; import moment, { Moment } from "moment"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; import { useBinAPI, useComponentAPI } from "providers"; import React, { useEffect, useRef, useState } from "react"; const CHECKPOINT_COUNT = 10; interface StatusCheckpoint { time: Moment; timeString: string; statusBushels: number; cables: pond.GrainCable[]; fans: pond.BinFan[]; heaters: pond.BinHeater[]; } interface Props { bin: Bin; setBin: React.Dispatch>; componentDevices: Map; } type ReadingSlot = { timestamp: string; values: number[]; }; type CableReadingsAtCheckpoint = { temperature?: ReadingSlot; humidity?: ReadingSlot; moisture?: ReadingSlot; }; /** * Per-checkpoint bucket for boolean output components (fans, heaters). * Only the most recent boolean reading in the time slice is kept. */ type BooleanStateAtCheckpoint = { state?: ReadingSlot; // values[0] is the boolean (1 = on, 0 = off) }; /** Per-checkpoint bucket: cable key → latest temp/humidity/moisture readings in that time slice. */ type CheckpointReadings = Map; /** Per-checkpoint bucket: component key → latest boolean state in that time slice. */ type CheckpointBooleanStates = Map; /** * Returns true when `candidate` should replace `existing` in a bucket. * Used so multiple samples in the same checkpoint keep only the newest reading per type. */ function readingIsNewer(candidate: ReadingSlot, existing?: ReadingSlot): boolean { if (!existing) { return true; } return moment(candidate.timestamp).valueOf() > moment(existing.timestamp).valueOf(); } /** * Stores one sampled reading on a cable's in-progress bucket state. * Routes by measurement type: temperature → celcius nodes, percent RH → humidity nodes, * grain EMC → moisture nodes. Ignores the update if an equal-or-newer reading is already stored. */ function upsertReading( readings: CableReadingsAtCheckpoint, type: quack.MeasurementType, timestamp: string, values: number[] ): void { const slot: ReadingSlot = { timestamp, values }; if (type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { if (readingIsNewer(slot, readings.temperature)) { readings.temperature = slot; } } else if (type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) { if (readingIsNewer(slot, readings.humidity)) { readings.humidity = slot; } } else if (type === quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC) { if (readingIsNewer(slot, readings.moisture)) { readings.moisture = slot; } } } /** * A trimmed record of a single component-settings history entry: just the timestamp and the * top-node value (`grain_filled_to`) that was in effect from that point forward. */ type TopNodeHistoryEntry = { timestamp: Moment; topNode: number; }; function buildTopNodeHistory( history: pond.ComponentHistory[] ): TopNodeHistoryEntry[] { return history .filter(h => h.component && h.component.grainFilledTo > 0 && h.timestamp) .map(h => ({ timestamp: moment(h.timestamp), topNode: h.component ? h.component.grainFilledTo : 0, })) .sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); } function topNodeAtTime( history: TopNodeHistoryEntry[], checkpointTime: Moment ): number | undefined { if (history.length === 0) return undefined; const t = checkpointTime.valueOf(); let lo = 0; let hi = history.length - 1; let result: number | undefined = undefined; while (lo <= hi) { const mid = (lo + hi) >>> 1; if (history[mid].timestamp.valueOf() <= t) { result = history[mid].topNode; lo = mid + 1; } else { hi = mid - 1; } } return result; } /** * A single recorded bushel estimate with its timestamp, sourced from bin object measurements. */ type BushelEntry = { timestamp: Moment; bushels: number; }; function buildBushelHistory( response: pond.ListObjectMeasurementsResponse ): BushelEntry[] { const entries: BushelEntry[] = []; response.measurements.forEach(um => { const isBushelType = um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIDAR || um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE || um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIBRACART; if (!isBushelType) return; um.timestamps.forEach((ts, i) => { const valueArray = um.values[i]; if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) return; entries.push({ timestamp: moment(ts), bushels: valueArray.values[0], }); }); }); return entries.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); } function bushelsAtTime( history: BushelEntry[], checkpointTime: Moment ): number | undefined { if (history.length === 0) return undefined; const t = checkpointTime.valueOf(); let lo = 0; let hi = history.length - 1; let result: number | undefined = undefined; while (lo <= hi) { const mid = (lo + hi) >>> 1; if (history[mid].timestamp.valueOf() <= t) { result = history[mid].bushels; lo = mid + 1; } else { hi = mid - 1; } } return result; } /** * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive). * These are the playback frames — not derived from measurement data. */ function buildCheckpointTimes(start: Moment, end: Moment, count: number): Moment[] { const startMs = start.valueOf(); const endMs = end.valueOf(); if (count <= 1 || endMs <= startMs) { return [moment(startMs)]; } const span = endMs - startMs; return Array.from({ length: count }, (_, i) => moment(startMs + (span * i) / (count - 1)) ); } /** * Maps a measurement timestamp to a checkpoint bucket index in [0, count - 1]. * The [start, end] range is split into `count` equal-width segments; a reading lands in * the segment it falls into. Times before start → 0, after end → last bucket. */ function checkpointIndexForTimestamp( timestamp: Moment, start: Moment, end: Moment, count: number ): number { const startMs = start.valueOf(); const endMs = end.valueOf(); if (count <= 1 || endMs <= startMs) { return 0; } const t = timestamp.valueOf(); if (t <= startMs) { return 0; } if (t >= endMs) { return count - 1; } const fraction = (t - startMs) / (endMs - startMs); const idx = Math.floor(fraction * count); return Math.min(idx, count - 1); } /** * Creates `count` empty buckets, each pre-populated with an empty reading map per cable key. * Output length matches checkpoint count; index aligns with `buildCheckpointTimes`. */ function emptyCheckpointReadings(cableKeys: string[], count: number): CheckpointReadings[] { return Array.from({ length: count }, () => { const map: CheckpointReadings = new Map(); cableKeys.forEach(key => map.set(key, {})); return map; }); } /** * Creates `count` empty boolean state buckets, each pre-populated per component key. */ function emptyCheckpointBooleanStates( componentKeys: string[], count: number ): CheckpointBooleanStates[] { return Array.from({ length: count }, () => { const map: CheckpointBooleanStates = new Map(); componentKeys.forEach(key => map.set(key, {})); return map; }); } /** * Walks every sampled measurement from each cable response and places it into a time bucket. * `measurementResponses[i]` corresponds to `cableKeys[i]` (same order as the sample requests). * Within a bucket, only the newest reading per measurement type is kept per cable. */ function assignMeasurementsToCheckpoints( cableKeys: string[], measurementResponses: pond.SampleUnitMeasurementsResponse[], start: Moment, end: Moment, count: number ): CheckpointReadings[] { const buckets = emptyCheckpointReadings(cableKeys, count); measurementResponses.forEach((response, cableIndex) => { const cableKey = cableKeys[cableIndex]; if (!cableKey) { return; } response.measurements.forEach(um => { um.timestamps.forEach((ts, i) => { const valueArray = um.values[i]; if (!ts || !valueArray || valueArray.error) { return; } const idx = checkpointIndexForTimestamp(moment(ts), start, end, count); const readings = buckets[idx].get(cableKey)!; upsertReading(readings, um.type, ts, valueArray.values); }); }); }); return buckets; } /** * Walks every sampled measurement from each boolean output component (fan/heater) response * and places the boolean state into a time bucket. Only the newest MEASUREMENT_TYPE_BOOLEAN * reading per component per bucket is kept. */ function assignBooleanStatesToCheckpoints( componentKeys: string[], measurementResponses: pond.SampleUnitMeasurementsResponse[], start: Moment, end: Moment, count: number ): CheckpointBooleanStates[] { const buckets = emptyCheckpointBooleanStates(componentKeys, count); measurementResponses.forEach((response, componentIndex) => { const componentKey = componentKeys[componentIndex]; if (!componentKey) { return; } response.measurements.forEach(um => { if (um.type !== quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN) { return; } um.timestamps.forEach((ts, i) => { const valueArray = um.values[i]; if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) { return; } const slot: ReadingSlot = { timestamp: ts, values: valueArray.values }; const idx = checkpointIndexForTimestamp(moment(ts), start, end, count); const entry = buckets[idx].get(componentKey)!; if (readingIsNewer(slot, entry.state)) { entry.state = slot; } }); }); }); return buckets; } /** * Picks the newest timestamp across temp, humidity, and moisture slots for a cable. * Used as `lastRead` on the reconstructed `pond.GrainCable`. */ function latestTimestamp(readings: CableReadingsAtCheckpoint): string | undefined { const slots = [readings.temperature, readings.humidity, readings.moisture].filter( (s): s is ReadingSlot => s !== undefined ); if (slots.length === 0) { return undefined; } return slots.reduce( (latest, slot) => moment(slot.timestamp).valueOf() > moment(latest).valueOf() ? slot.timestamp : latest, slots[0].timestamp ); } /** * Combines readings carried forward from earlier checkpoints with readings in the current bucket. * Any type missing in the current bucket keeps the previous checkpoint's value so playback * does not drop cables back to template defaults between sparse samples. */ function mergeCarriedReadings( carried: CableReadingsAtCheckpoint, current: CableReadingsAtCheckpoint ): CableReadingsAtCheckpoint { return { temperature: current.temperature ?? carried.temperature, humidity: current.humidity ?? carried.humidity, moisture: current.moisture ?? carried.moisture }; } /** * Combines boolean states carried forward from earlier checkpoints with the current bucket. * If no reading exists in the current bucket the last known state is preserved, so fans/heaters * don't flicker back to their template default between sparse samples. */ function mergeCarriedBooleanState( carried: BooleanStateAtCheckpoint, current: BooleanStateAtCheckpoint ): BooleanStateAtCheckpoint { return { state: current.state ?? carried.state }; } /** * Clones a live `pond.GrainCable` template (name, key, device, top node, etc.) and overwrites * node arrays from reconciled readings. `lastRead` is the newest measurement time, or the * checkpoint time when no readings exist yet for that cable. */ function buildGrainCableFromReadings( template: pond.GrainCable, readings: CableReadingsAtCheckpoint, checkpointTime: Moment ): pond.GrainCable { const cable = pond.GrainCable.fromObject(cloneDeep(template)); // Clear template values so empty checkpoints don't inherit live data cable.celcius = []; cable.relativeHumidity = []; cable.moisture = []; if (readings.temperature) { cable.celcius = [...readings.temperature.values]; } if (readings.humidity) { cable.relativeHumidity = [...readings.humidity.values]; } if (readings.moisture) { cable.moisture = [...readings.moisture.values]; } cable.lastRead = latestTimestamp(readings) ?? checkpointTime.toISOString(); return cable; } /** * Clones a `pond.BinFan` template and overwrites its `state` from the reconciled boolean * reading. If no reading has been seen yet the template state is left as-is (carry-forward * will eventually set it once the first sample arrives). */ function buildBinFanFromState( template: pond.BinFan, booleanState: BooleanStateAtCheckpoint ): pond.BinFan { const fan = pond.BinFan.fromObject(cloneDeep(template)); if (booleanState.state !== undefined) { fan.state = booleanState.state.values[0] !== 0; } return fan; } /** * Clones a `pond.BinHeater` template and overwrites its `state` from the reconciled boolean * reading. Same carry-forward semantics as `buildBinFanFromState`. */ function buildBinHeaterFromState( template: pond.BinHeater, booleanState: BooleanStateAtCheckpoint ): pond.BinHeater { const heater = pond.BinHeater.fromObject(cloneDeep(template)); if (booleanState.state !== undefined) { heater.state = booleanState.state.values[0] !== 0; } return heater; } /** * End-to-end pipeline: evenly spaced times → bucket measurements → carry-forward merge → cables + fans + heaters. * Returns one `StatusCheckpoint` per frame with `time` (playback moment), `cables` * (full `grainCables` array), `fans`, and `heaters` as they would have appeared at that moment. */ function buildStatusCheckpoints( cableTemplates: pond.GrainCable[], fanTemplates: pond.BinFan[], heaterTemplates: pond.BinHeater[], cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[], fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[], heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], /** Sorted top-node history per cable key. Pass an empty Map when not needed. */ topNodeHistories: Map, /** Sorted bushel history derived from bin object measurements. */ bushelHistory: BushelEntry[], /** Fallback bushel count used when no measurement precedes a checkpoint. */ fallbackBushels: number, start: Moment, end: Moment, count: number = CHECKPOINT_COUNT ): StatusCheckpoint[] { const times = buildCheckpointTimes(start, end, count); console.log(times); // --- cables --- const cableKeys = cableTemplates.map(c => c.key); const cableBuckets = assignMeasurementsToCheckpoints( cableKeys, cableMeasurementResponses, start, end, count ); // --- fans --- const fanKeys = fanTemplates.map(f => f.key); const fanBuckets = assignBooleanStatesToCheckpoints( fanKeys, fanMeasurementResponses, start, end, count ); // --- heaters --- const heaterKeys = heaterTemplates.map(h => h.key); const heaterBuckets = assignBooleanStatesToCheckpoints( heaterKeys, heaterMeasurementResponses, start, end, count ); // Carry-forward state per component const carriedByCable = new Map(); cableKeys.forEach(key => carriedByCable.set(key, {})); const carriedByFan = new Map(); fanKeys.forEach(key => carriedByFan.set(key, {})); const carriedByHeater = new Map(); heaterKeys.forEach(key => carriedByHeater.set(key, {})); return cableBuckets.map((cableBucket, i) => { const time = times[i]; // Build cables const cables = cableTemplates.map(template => { const key = template.key; const bucketReadings = cableBucket.get(key) ?? {}; const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); carriedByCable.set(key, merged); const cable = buildGrainCableFromReadings(template, merged, time); // Apply the most recent top-node setting that was in effect at this checkpoint time. // Falls back to the template's current top_node when no history entry precedes this time. const history = topNodeHistories.get(key); if (history) { const tn = topNodeAtTime(history, time); if (tn !== undefined) { cable.topNode = tn; } } return cable; }); // Build fans const fans = fanTemplates.map(template => { const key = template.key; const bucketState = fanBuckets[i].get(key) ?? {}; const merged = mergeCarriedBooleanState(carriedByFan.get(key)!, bucketState); carriedByFan.set(key, merged); return buildBinFanFromState(template, merged); }); // Build heaters const heaters = heaterTemplates.map(template => { const key = template.key; const bucketState = heaterBuckets[i].get(key) ?? {}; const merged = mergeCarriedBooleanState(carriedByHeater.get(key)!, bucketState); carriedByHeater.set(key, merged); return buildBinHeaterFromState(template, merged); }); // Resolve bushels: binary search gives the most recent recorded value at or before this // checkpoint (carry-forward is implicit in the search). Falls back to the current live // status bushels when the playback range predates all recorded measurements. const statusBushels = bushelsAtTime(bushelHistory, time) ?? fallbackBushels; return { time, cables, fans, heaters, timeString: time.toISOString(), statusBushels }; }).filter(checkpoint => // Keep checkpoints that have at least some cable data checkpoint.cables.some(cable => cable.celcius.length > 0 || cable.relativeHumidity.length > 0 || cable.moisture.length > 0 ) ); } /** * Calculates the number of checkpoints to create based on the start and end dates. * Is hard coded to space the checkpoints 6 hours apart. * @param start - The start date * @param end - The end date * @returns The number of checkpoints to create */ function checkpointCountFromRange(start: Moment, end: Moment): number { const hours = end.diff(start, 'hours'); return Math.max(1, Math.floor(hours / 6) + 1); } /** * This component is used to reconcile measurements with an array of bin objects in order to see how the bin has progressed over a set period. * It gets the measurements for connected components, and uses those measurements to create a bin and simulate what its status would have been * at that time, creates an array of bins doing this and then uses a timer in a loop to change the bin in the status. * Fans and heaters are included so playback also reflects which equipment was running at each checkpoint. * @returns a JSX Element */ export default function BinPlayer(props: Props) { const { bin, componentDevices, setBin } = props; const isMobile = useMobile(); const componentAPI = useComponentAPI(); const binAPI = useBinAPI(); const [dateSelect, setDateSelect] = useState(0); const [startDate, setStartDate] = useState(moment().subtract(1, 'week')); const [endDate, setEndDate] = useState(moment); const [dateRangeDialog, setDateRangeDialog] = useState(false); const [tempStartDate, setTempStartDate] = useState(moment().subtract(1, 'week')); const [tempEndDate, setTempEndDate] = useState(moment()); const [currentCheckpointTime, setCurrentCheckpointTime] = useState(undefined); const [displayProgress, setDisplayProgress] = useState(0); const totalCheckpoints = useRef(0); const checkpointStartTime = useRef(0); const currentCheckpointIndex = useRef(0); const isPlaying = useRef(false); const PLAYBACK_INTERVAL = 1000; const [isPlayingState, setIsPlayingState] = useState(false); const stopPlayer = () => { isPlaying.current = false; }; const originalBin = useRef(null); useEffect(() => { if (!isPlayingState) return; const fps = 30; const interval = 1000 / fps; const stepDuration = PLAYBACK_INTERVAL; const timer = setInterval(() => { if (!isPlayingState) { clearInterval(timer); setDisplayProgress(0); return; } const total = totalCheckpoints.current; if (total === 0) return; const idx = currentCheckpointIndex.current; const elapsed = Date.now() - checkpointStartTime.current; const stepProgress = Math.min(elapsed / stepDuration, 1); const raw = total <= 1 ? 100 : ((idx + stepProgress) / (total - 1)) * 100; setDisplayProgress(Math.min(raw, 100)); }, interval); return () => clearInterval(timer); }, [isPlayingState]); /** * Fetches sampled unit measurements for every grain cable, fan, and heater in the current * bin status, then builds `statusCheckpoints` for playback over [startDate, endDate]. */ const startPlayer = async () => { originalBin.current = cloneDeep(bin); isPlaying.current = true; setIsPlayingState(true); // Cable requests const cableSampleRequests = bin.status.grainCables.map(cable => { const deviceID = componentDevices.get(cable.key) ?? 0; return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); // Cable top-node history requests — one per cable, covering the full playback range. const cableHistoryRequests = bin.status.grainCables.map(cable => { const deviceID = componentDevices.get(cable.key) ?? 0; return componentAPI.listHistoryBetween( deviceID, cable.key, startDate.toISOString(), endDate.toISOString() ); }); // Fan requests const fanSampleRequests = bin.status.fans.map(fan => { const deviceID = componentDevices.get(fan.key) ?? 0; return componentAPI.sampleUnitMeasurements(deviceID, fan.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); // Heater requests const heaterSampleRequests = bin.status.heaters.map(heater => { const deviceID = componentDevices.get(heater.key) ?? 0; return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); const binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc'); const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([ Promise.all(cableSampleRequests), Promise.all(fanSampleRequests), Promise.all(heaterSampleRequests), Promise.all(cableHistoryRequests), binMeasurementsRequest, ]); // Build a per-cable sorted top-node history map for use during checkpoint construction. const topNodeHistories = new Map(); bin.status.grainCables.forEach((cable, i) => { const historyResp = cableHistoryResponses[i]; const entries = buildTopNodeHistory(historyResp?.data?.history ?? []); topNodeHistories.set(cable.key, entries); }); // Build sorted bushel history from bin object measurements. const bushelHistory = buildBushelHistory(binMeasurementsResponse.data); const checkpoints = buildStatusCheckpoints( bin.status.grainCables, bin.status.fans, bin.status.heaters, cableResponses.map(r => r.data), fanResponses.map(r => r.data), heaterResponses.map(r => r.data), topNodeHistories, bushelHistory, bin.status.grainBushels, startDate, endDate, checkpointCountFromRange(startDate, endDate) ); for (let i = 0; i < checkpoints.length; i++) { if (!isPlaying.current) { setBin(originalBin.current!); setCurrentCheckpointTime(undefined); setIsPlayingState(false); return; } currentCheckpointIndex.current = i; totalCheckpoints.current = checkpoints.length; checkpointStartTime.current = Date.now(); const newBin = cloneDeep(originalBin.current!); newBin.status.grainCables = checkpoints[i].cables; newBin.status.fans = checkpoints[i].fans; newBin.status.heaters = checkpoints[i].heaters; newBin.status.grainBushels = checkpoints[i].statusBushels; setBin(newBin); setCurrentCheckpointTime(checkpoints[i].time); await new Promise(res => setTimeout(res, PLAYBACK_INTERVAL)); } isPlaying.current = false; setIsPlayingState(false); setBin(originalBin.current!); setCurrentCheckpointTime(undefined); }; const datePickerDialog = () => { return ( setDateRangeDialog(false)} aria-labelledby="date-range-dialog"> { setTempStartDate(moment(e.target.value)); }} /> { setTempEndDate(moment(e.target.value)); }} /> ); }; const dateSelector = () => { return ( {dateSelect === 3 && ( )} ); }; return ( {datePickerDialog()} {/* start/stop button */} {isPlayingState ? ( ) : ( )} {/* progress bar and date display */} {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} {startDate.format("MMM D, YYYY")} {endDate.format("MMM D, YYYY")} {/* date range selector */} {dateSelector()} ); }