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 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";
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue