From cd5fbb29b76319132aec05d0ba72b6e3bb48a262 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 12 Jun 2026 14:05:32 -0600 Subject: [PATCH] changing the visualizer to use the bins status controllers rather than controllers being passed in --- src/bin/BinPlayer.tsx | 542 +++++++++++++------ src/bin/bin3dVisualizer.tsx | 92 ++-- src/bin/binSummary/BinSummary.tsx | 4 +- src/bin/binSummary/components/binDetails.tsx | 4 +- 4 files changed, 418 insertions(+), 224 deletions(-) diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index edecb7d..88bc45c 100644 --- a/src/bin/BinPlayer.tsx +++ b/src/bin/BinPlayer.tsx @@ -1,8 +1,8 @@ -import { DateRange, PlayArrow, Start, Stop } from "@mui/icons-material"; -import { Box, Button, darken, DialogActions, DialogContent, FormControl, IconButton, LinearProgress, MenuItem, Select, TextField, Typography } from "@mui/material"; +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 { GetDefaultDateRange } from "common/time/DateRange"; +import { useMobile } from "hooks"; import { cloneDeep } from "lodash"; import { Bin } from "models"; import moment, { Moment } from "moment"; @@ -17,6 +17,8 @@ interface StatusCheckpoint { time: Moment; timeString: string; cables: pond.GrainCable[]; + fans: pond.BinFan[]; + heaters: pond.BinHeater[]; } interface Props { @@ -36,9 +38,20 @@ type CableReadingsAtCheckpoint = { 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. @@ -133,6 +146,20 @@ function emptyCheckpointReadings(cableKeys: string[], count: number): Checkpoint }); } +/** + * 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). @@ -169,6 +196,48 @@ function assignMeasurementsToCheckpoints( 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`. @@ -203,6 +272,20 @@ function mergeCarriedReadings( }; } +/** + * 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 @@ -235,50 +318,136 @@ function buildGrainCableFromReadings( } /** - * End-to-end pipeline: evenly spaced times → bucket measurements → carry-forward merge → cables. - * Returns one `StatusCheckpoint` per frame with `time` (playback moment) and `cables` - * (full `grainCables` array as it would have appeared at that moment). + * 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( - templates: pond.GrainCable[], - measurementResponses: pond.SampleUnitMeasurementsResponse[], + cableTemplates: pond.GrainCable[], + fanTemplates: pond.BinFan[], + heaterTemplates: pond.BinHeater[], + cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[], + fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[], + heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], start: Moment, end: Moment, count: number = CHECKPOINT_COUNT ): StatusCheckpoint[] { const times = buildCheckpointTimes(start, end, count); - console.log(times) - const cableKeys = templates.map(c => c.key); - const buckets = assignMeasurementsToCheckpoints( + console.log(times); + + // --- cables --- + const cableKeys = cableTemplates.map(c => c.key); + const cableBuckets = assignMeasurementsToCheckpoints( cableKeys, - measurementResponses, + 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, {})); - return buckets.map((bucket, i) => { + 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]; - const cables = templates.map(template => { + + // Build cables + const cables = cableTemplates.map(template => { const key = template.key; - const bucketReadings = bucket.get(key) ?? {}; + const bucketReadings = cableBucket.get(key) ?? {}; const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); carriedByCable.set(key, merged); return buildGrainCableFromReadings(template, merged, time); }); - return { time, cables, timeString: time.toISOString() }; - }).filter(checkpoint => //filter the checkpoints to only include ones that have readings + + // 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); + }); + + return { time, cables, fans, heaters, timeString: time.toISOString() }; + }).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. @@ -293,38 +462,39 @@ function checkpointCountFromRange(start: Moment, end: Moment): number { /** * 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 measurments 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 + * 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(); - //the default start and end dates are 1 week ago and now 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 [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 //playback time between checkpoints in ms + 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; // must match your setTimeout delay - + const stepDuration = PLAYBACK_INTERVAL; + const timer = setInterval(() => { if (!isPlayingState) { clearInterval(timer); @@ -333,165 +503,189 @@ export default function BinPlayer(props: Props) { } const total = totalCheckpoints.current; if (total === 0) return; - + const idx = currentCheckpointIndex.current; - // and change the progress formula to account for the last checkpoint reaching 100%: const elapsed = Date.now() - checkpointStartTime.current; const stepProgress = Math.min(elapsed / stepDuration, 1); - // use (total - 1) as denominator so the last checkpoint goes from ~end to 100% const raw = total <= 1 ? 100 : ((idx + stepProgress) / (total - 1)) * 100; setDisplayProgress(Math.min(raw, 100)); }, interval); - + return () => clearInterval(timer); - }, [isPlayingState]); // re-run when playing state changes + }, [isPlayingState]); /** - * Fetches sampled unit measurements for every grain cable in the current bin status, - * then builds `statusCheckpoints` for playback over [startDate, endDate]. + * 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); // capture before async work begins - isPlaying.current = true; - setIsPlayingState(true) + const startPlayer = async () => { + originalBin.current = cloneDeep(bin); + isPlaying.current = true; + setIsPlayingState(true); - const sampleRequests = 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 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); + }); - const responses = await Promise.all(sampleRequests); - const checkpoints = buildStatusCheckpoints( - bin.status.grainCables, - responses.map(r => r.data), - startDate, - endDate, - checkpointCountFromRange(startDate, endDate) - ); + // 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); + }); - for (let i = 0; i < checkpoints.length; i++) { - if (!isPlaying.current) { - setBin(originalBin.current!); - setCurrentCheckpointTime(undefined); - setIsPlayingState(false) - return; + // 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 [cableResponses, fanResponses, heaterResponses] = await Promise.all([ + Promise.all(cableSampleRequests), + Promise.all(fanSampleRequests), + Promise.all(heaterSampleRequests), + ]); + + 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), + 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; + + setBin(newBin); + setCurrentCheckpointTime(checkpoints[i].time); + await new Promise(res => setTimeout(res, PLAYBACK_INTERVAL)); } - currentCheckpointIndex.current = i; - totalCheckpoints.current = checkpoints.length; - checkpointStartTime.current = Date.now(); - const newBin = cloneDeep(originalBin.current!); - newBin.status.grainCables = checkpoints[i].cables; - 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)); - }} - /> - - - - - - - ); -}; + isPlaying.current = false; + setIsPlayingState(false); + setBin(originalBin.current!); + setCurrentCheckpointTime(undefined); + }; -const dateSelector = () => { - return ( - - - { - setDateSelect(event.target.value as number); - let currentDate = moment(); - setEndDate(currentDate); - switch (event.target.value) { - case 0: - setStartDate(currentDate.clone().subtract(1, 'week')); - break; - case 1: - setStartDate(currentDate.clone().subtract(2, 'weeks')); - break; - case 2: - setStartDate(currentDate.clone().subtract(4, 'weeks')); - break; - - } - }} - > - 1 Week - 2 Weeks - 4 Weeks - Custom - - - {dateSelect === 3 && ( - - )} - - ) -} + }, + '& .MuiOutlinedInput-notchedOutline': { border: 'none' }, + '&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, + }} + onChange={event => { + setDateSelect(event.target.value as number); + let currentDate = moment(); + setEndDate(currentDate); + switch (event.target.value) { + case 0: + setStartDate(currentDate.clone().subtract(1, 'week')); + break; + case 1: + setStartDate(currentDate.clone().subtract(2, 'weeks')); + break; + case 2: + setStartDate(currentDate.clone().subtract(4, 'weeks')); + break; + } + }} + > + 1 Week + 2 Weeks + 4 Weeks + Custom + + + {dateSelect === 3 && ( + + )} + + ); + }; return ( @@ -500,18 +694,18 @@ const dateSelector = () => { {/* 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")} + {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")} + {startDate.format("MMM D, YYYY")} + {endDate.format("MMM D, YYYY")} {/* date range selector */} diff --git a/src/bin/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx index 50df08e..06bc722 100644 --- a/src/bin/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -16,8 +16,8 @@ interface Props { bin: Bin // cables?: GrainCable[] devices: Device[] - fans?: Controller[] - heaters?: Controller[] + // fans?: Controller[] + // heaters?: Controller[] permissions: pond.Permission[] componentDevices: Map componentMap: Map @@ -25,7 +25,7 @@ interface Props { updateBinCallback?: (bin: Bin) => void } export default function bin3dVisualizer(props: Props){ - const {bin, fans, heaters, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props + const {bin, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props const theme = useTheme() const [showHeatmap, setShowHeatmap] = useState(true) // const [showHotspots, setShowHotspots] = useState(false) @@ -86,7 +86,6 @@ export default function bin3dVisualizer(props: Props){ '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, }} onChange={event => { - console.log("change bin mode") setBinMode(event.target.value as pond.BinMode) setOpenModeChange(true) }} @@ -159,38 +158,37 @@ export default function bin3dVisualizer(props: Props){ ) } - const controllerState = (controller: Controller) => { - let state = false - controller.status.lastGoodMeasurement.forEach(um => { - if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ - if(um.values.length > 0){ - let readings = um.values - if(readings[readings.length-1].values.length > 0){ - let nodes = readings[readings.length-1].values - if (nodes[nodes.length -1] === 1){ - state = true - } - } - } - } - }) - return state - } + // const controllerState = (controller: Controller) => { + // let state = false + // controller.status.lastGoodMeasurement.forEach(um => { + // if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ + // if(um.values.length > 0){ + // let readings = um.values + // if(readings[readings.length-1].values.length > 0){ + // let nodes = readings[readings.length-1].values + // if (nodes[nodes.length -1] === 1){ + // state = true + // } + // } + // } + // } + // }) + // return state + // } const controllerDisplay = () => { - if (fans && fans.length > 1 || heaters && heaters.length > 1){ - - + if ((bin.status.fans && bin.status.fans.length > 0) || (bin.status.heaters && bin.status.heaters.length > 0)){ return ( Controllers - {fans && fans.map(fan => { - let isOn = controllerState(fan) + {bin.status.fans && bin.status.fans.map(fan => { + // let isOn = controllerState(fan) + return ( - {fan.name()} + {fan.name} - {isOn ? "ON" : "OFF"} + {fan.state ? "ON" : "OFF"} )})} - {heaters && heaters.map(heater => { - let isOn = controllerState(heater) + {bin.status.heaters && bin.status.heaters.map(heater => { + // let isOn = controllerState(heater) return ( - {heater.name()} + {heater.name} - {isOn ? "ON" : "OFF"} + {heater.state ? "ON" : "OFF"} @@ -382,17 +380,23 @@ export default function bin3dVisualizer(props: Props){ //filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status //is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear let filtered: Component[] = [] - if(fans){ - fans.forEach((f) => { - if(componentDevices.get(f.key()) === d.id()){ - filtered.push(f.asComponent()) + if(bin.status.fans){ + bin.status.fans.forEach((f) => { + if(componentDevices.get(f.key) === d.id()){ + let comp = componentMap.get(f.key) + if(comp){ + filtered.push(comp) + } } }) } - if(heaters){ - heaters.forEach((h) => { - if(componentDevices.get(h.key()) === d.id()){ - filtered.push(h.asComponent()) + if(bin.status.heaters){ + bin.status.heaters.forEach((h) => { + if(componentDevices.get(h.key) === d.id()){ + let comp = componentMap.get(h.key) + if(comp){ + filtered.push(comp) + } } }) } diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 6d39c25..293a720 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -341,9 +341,7 @@ export default function BinSummary(props: Props){ :