started work on the 'player' for bins

This commit is contained in:
csawatzky 2026-06-04 16:47:07 -06:00
parent ac26455110
commit 1dbf3a96c6
4 changed files with 327 additions and 4 deletions

317
src/bin/BinPlayer.tsx Normal file
View file

@ -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<React.SetStateAction<Bin>>;
componentDevices: Map<string, number>;
}
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<string, CableReadingsAtCheckpoint>;
/**
* 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<string, CableReadingsAtCheckpoint>();
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<Moment>(defaultDateRange.start);
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
const [statusCheckpoints, setStatusCheckpoints] = useState<StatusCheckpoint[]>([]);
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 (
<React.Fragment>
<Button onClick={startPlayer}>play</Button>
</React.Fragment>
);
}

View file

@ -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";

View file

@ -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()}
<Grid2 container spacing={2}>
<Grid2 size={isMobile ? 12 : 7}>
<Card raised sx={{height: 600}}>
<Card raised sx={{height: 600, position: "relative"}}>
<Bin3dVisualizer
bin={bin}
fans={fans}
@ -401,6 +402,11 @@ export default function BinSummary(props: Props){
binPrefs={binPrefs}
updateBinCallback={updateBinCallback}
/>
{setBin &&
<Box position="absolute" bottom={10}>
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
</Box>
}
</Card>
</Grid2>
<Grid2 size={isMobile ? 12 : 5}>

View file

@ -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]