started work on the 'player' for bins
This commit is contained in:
parent
ac26455110
commit
1dbf3a96c6
4 changed files with 327 additions and 4 deletions
317
src/bin/BinPlayer.tsx
Normal file
317
src/bin/BinPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import { Controller } from "models/Controller";
|
||||||
import { pond, quack } from "protobuf-ts/pond";
|
import { pond, quack } from "protobuf-ts/pond";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import NodeControls from "./nodeControls";
|
import NodeControls from "./binSummary/components/nodeControls";
|
||||||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||||
import { CableData } from "bin/3dView/Data/BuildCableData";
|
import { CableData } from "bin/3dView/Data/BuildCableData";
|
||||||
import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
|
import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Box, Card, DialogContent, DialogTitle, Grid2, IconButton, LinearProgress, Slider, Switch, Tooltip, Typography } from "@mui/material";
|
import { Box, Card, DialogContent, DialogTitle, Grid2, IconButton, LinearProgress, Slider, Switch, Tooltip, Typography } from "@mui/material";
|
||||||
import { useMobile } from "hooks";
|
import { useMobile } from "hooks";
|
||||||
import { Bin, Component, Device } from "models";
|
import { Bin, Component, Device } from "models";
|
||||||
import Bin3dVisualizer from "./components/bin3dVisualizer";
|
import Bin3dVisualizer from "../bin3dVisualizer";
|
||||||
// import GrassIcon from "@mui/icons-material/Grass"
|
// import GrassIcon from "@mui/icons-material/Grass"
|
||||||
import { grey, orange, red } from "@mui/material/colors";
|
import { grey, orange, red } from "@mui/material/colors";
|
||||||
import { AccessTime, Spa } from "@mui/icons-material";
|
import { AccessTime, Spa } from "@mui/icons-material";
|
||||||
|
|
@ -19,6 +19,7 @@ import ChangeGrainDialog from "grain/ChangeGrainDialog";
|
||||||
import { useGlobalState } from "providers";
|
import { useGlobalState } from "providers";
|
||||||
import GrainTransaction from "grain/GrainTransaction";
|
import GrainTransaction from "grain/GrainTransaction";
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
|
import BinPlayer from "bin/BinPlayer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bin: Bin
|
bin: Bin
|
||||||
|
|
@ -389,7 +390,7 @@ export default function BinSummary(props: Props){
|
||||||
{isMobile ? inventorySummaryMobile() : inventorySummaryDesktop()}
|
{isMobile ? inventorySummaryMobile() : inventorySummaryDesktop()}
|
||||||
<Grid2 container spacing={2}>
|
<Grid2 container spacing={2}>
|
||||||
<Grid2 size={isMobile ? 12 : 7}>
|
<Grid2 size={isMobile ? 12 : 7}>
|
||||||
<Card raised sx={{height: 600}}>
|
<Card raised sx={{height: 600, position: "relative"}}>
|
||||||
<Bin3dVisualizer
|
<Bin3dVisualizer
|
||||||
bin={bin}
|
bin={bin}
|
||||||
fans={fans}
|
fans={fans}
|
||||||
|
|
@ -401,6 +402,11 @@ export default function BinSummary(props: Props){
|
||||||
binPrefs={binPrefs}
|
binPrefs={binPrefs}
|
||||||
updateBinCallback={updateBinCallback}
|
updateBinCallback={updateBinCallback}
|
||||||
/>
|
/>
|
||||||
|
{setBin &&
|
||||||
|
<Box position="absolute" bottom={10}>
|
||||||
|
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
</Card>
|
</Card>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
<Grid2 size={isMobile ? 12 : 5}>
|
<Grid2 size={isMobile ? 12 : 5}>
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default function BinTableView(props: Props) {
|
||||||
const [moistureEntry, setMoistureEntry] = useState("")
|
const [moistureEntry, setMoistureEntry] = useState("")
|
||||||
const [moistureTarget, setMoistureTarget] = useState(0)
|
const [moistureTarget, setMoistureTarget] = useState(0)
|
||||||
const [openExpandedCables, setOpenExpandedCables] = useState(false)
|
const [openExpandedCables, setOpenExpandedCables] = useState(false)
|
||||||
const mobileView = useMobile()
|
const isMobile = useMobile()
|
||||||
const inBounds = green[600]
|
const inBounds = green[600]
|
||||||
const caution = yellow[800]
|
const caution = yellow[800]
|
||||||
const warning = red[700]
|
const warning = red[700]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue