From 1dbf3a96c662e173b9ef16111a6f7fe0ce503fcb Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 4 Jun 2026 16:47:07 -0600 Subject: [PATCH] started work on the 'player' for bins --- src/bin/BinPlayer.tsx | 317 ++++++++++++++++++ .../components => }/bin3dVisualizer.tsx | 2 +- src/bin/binSummary/BinSummary.tsx | 10 +- src/bin/binTableView.tsx | 2 +- 4 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 src/bin/BinPlayer.tsx rename src/bin/{binSummary/components => }/bin3dVisualizer.tsx (99%) diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx new file mode 100644 index 0000000..9f530f7 --- /dev/null +++ b/src/bin/BinPlayer.tsx @@ -0,0 +1,317 @@ +import { Button } from "@mui/material"; +import { GetDefaultDateRange } from "common/time/DateRange"; +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 { useComponentAPI } from "providers"; +import React, { useEffect, useState } from "react"; + +const CHECKPOINT_COUNT = 10; + +interface StatusCheckpoint { + time: Moment; + cables: pond.GrainCable[]; +} + +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: cable key → latest temp/humidity/moisture readings in that time slice. */ +type CheckpointReadings = 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; + } + } +} + +/** + * 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; + }); +} + +/** + * 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; +} + +/** + * 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 + }; +} + +/** + * 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)); + + 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; +} + +/** + * 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). + */ +function buildStatusCheckpoints( + templates: pond.GrainCable[], + measurementResponses: pond.SampleUnitMeasurementsResponse[], + start: Moment, + end: Moment, + count: number = CHECKPOINT_COUNT +): StatusCheckpoint[] { + const times = buildCheckpointTimes(start, end, count); + const cableKeys = templates.map(c => c.key); + const buckets = assignMeasurementsToCheckpoints( + cableKeys, + measurementResponses, + start, + end, + count + ); + + const carriedByCable = new Map(); + cableKeys.forEach(key => carriedByCable.set(key, {})); + + return buckets.map((bucket, i) => { + const time = times[i]; + const cables = templates.map(template => { + const key = template.key; + const bucketReadings = bucket.get(key) ?? {}; + const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); + carriedByCable.set(key, merged); + return buildGrainCableFromReadings(template, merged, time); + }); + return { time, cables }; + }); +} + +/** + * 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 + * @returns a JSX Element + */ +export default function BinPlayer(props: Props) { + const { bin, componentDevices } = props; + const componentAPI = useComponentAPI(); + const defaultDateRange = GetDefaultDateRange(); + const [startDate, setStartDate] = useState(defaultDateRange.start); + const [endDate, setEndDate] = useState(defaultDateRange.end); + const [statusCheckpoints, setStatusCheckpoints] = useState([]); + + useEffect(()=>{ + console.log(statusCheckpoints) + },[statusCheckpoints]) + + /** + * Fetches sampled unit measurements for every grain cable in the current bin status, + * then builds `statusCheckpoints` for playback over [startDate, endDate]. + */ + const startPlayer = () => { + const sampleRequests = bin.status.grainCables.map(cable => { + const deviceID = componentDevices.get(cable.key) ?? 0; + return componentAPI.sampleUnitMeasurements( + deviceID, + cable.key, + startDate, + endDate, + 100 + ); + }); + + Promise.all(sampleRequests).then(responses => { + setStatusCheckpoints( + buildStatusCheckpoints( + bin.status.grainCables, + responses.map(r => r.data), + startDate, + endDate + ) + ); + }); + }; + + return ( + + + + ); +} diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx similarity index 99% rename from src/bin/binSummary/components/bin3dVisualizer.tsx rename to src/bin/bin3dVisualizer.tsx index a2b4b82..0ff9766 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -5,7 +5,7 @@ import { Controller } from "models/Controller"; import { pond, quack } from "protobuf-ts/pond"; import React, { useEffect } from "react"; import { useState } from "react"; -import NodeControls from "./nodeControls"; +import NodeControls from "./binSummary/components/nodeControls"; import { NodeData } from "bin/3dView/Data/BuildNodeData"; import { CableData } from "bin/3dView/Data/BuildCableData"; import ModeChangeDialog from "bin/conditioning/modeChangeDialog"; diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 54b7c82..e01e101 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -1,7 +1,7 @@ import { Box, Card, DialogContent, DialogTitle, Grid2, IconButton, LinearProgress, Slider, Switch, Tooltip, Typography } from "@mui/material"; import { useMobile } from "hooks"; import { Bin, Component, Device } from "models"; -import Bin3dVisualizer from "./components/bin3dVisualizer"; +import Bin3dVisualizer from "../bin3dVisualizer"; // import GrassIcon from "@mui/icons-material/Grass" import { grey, orange, red } from "@mui/material/colors"; import { AccessTime, Spa } from "@mui/icons-material"; @@ -19,6 +19,7 @@ import ChangeGrainDialog from "grain/ChangeGrainDialog"; import { useGlobalState } from "providers"; import GrainTransaction from "grain/GrainTransaction"; import { cloneDeep } from "lodash"; +import BinPlayer from "bin/BinPlayer"; interface Props { bin: Bin @@ -389,7 +390,7 @@ export default function BinSummary(props: Props){ {isMobile ? inventorySummaryMobile() : inventorySummaryDesktop()} - + + {setBin && + + + + } diff --git a/src/bin/binTableView.tsx b/src/bin/binTableView.tsx index 242f82a..ff3fdb0 100644 --- a/src/bin/binTableView.tsx +++ b/src/bin/binTableView.tsx @@ -67,7 +67,7 @@ export default function BinTableView(props: Props) { const [moistureEntry, setMoistureEntry] = useState("") const [moistureTarget, setMoistureTarget] = useState(0) const [openExpandedCables, setOpenExpandedCables] = useState(false) - const mobileView = useMobile() + const isMobile = useMobile() const inBounds = green[600] const caution = yellow[800] const warning = red[700]