870 lines
No EOL
30 KiB
TypeScript
870 lines
No EOL
30 KiB
TypeScript
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 { useMobile } from "hooks";
|
|
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 { useBinAPI, useComponentAPI } from "providers";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
|
|
const CHECKPOINT_COUNT = 10;
|
|
|
|
interface StatusCheckpoint {
|
|
time: Moment;
|
|
timeString: string;
|
|
statusBushels: number;
|
|
cables: pond.GrainCable[];
|
|
fans: pond.BinFan[];
|
|
heaters: pond.BinHeater[];
|
|
}
|
|
|
|
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 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<string, CableReadingsAtCheckpoint>;
|
|
|
|
/** Per-checkpoint bucket: component key → latest boolean state in that time slice. */
|
|
type CheckpointBooleanStates = Map<string, BooleanStateAtCheckpoint>;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A trimmed record of a single component-settings history entry: just the timestamp and the
|
|
* top-node value (`grain_filled_to`) that was in effect from that point forward.
|
|
*/
|
|
type TopNodeHistoryEntry = {
|
|
timestamp: Moment;
|
|
topNode: number;
|
|
};
|
|
|
|
function buildTopNodeHistory(
|
|
history: pond.ComponentHistory[]
|
|
): TopNodeHistoryEntry[] {
|
|
return history
|
|
.filter(h => h.component && h.component.grainFilledTo > 0 && h.timestamp)
|
|
.map(h => ({
|
|
timestamp: moment(h.timestamp),
|
|
topNode: h.component ? h.component.grainFilledTo : 0,
|
|
}))
|
|
.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
|
|
}
|
|
|
|
function topNodeAtTime(
|
|
history: TopNodeHistoryEntry[],
|
|
checkpointTime: Moment
|
|
): number | undefined {
|
|
if (history.length === 0) return undefined;
|
|
const t = checkpointTime.valueOf();
|
|
let lo = 0;
|
|
let hi = history.length - 1;
|
|
let result: number | undefined = undefined;
|
|
while (lo <= hi) {
|
|
const mid = (lo + hi) >>> 1;
|
|
if (history[mid].timestamp.valueOf() <= t) {
|
|
result = history[mid].topNode;
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A single recorded bushel estimate with its timestamp, sourced from bin object measurements.
|
|
*/
|
|
type BushelEntry = {
|
|
timestamp: Moment;
|
|
bushels: number;
|
|
};
|
|
|
|
function buildBushelHistory(
|
|
response: pond.ListObjectMeasurementsResponse
|
|
): BushelEntry[] {
|
|
const entries: BushelEntry[] = [];
|
|
|
|
response.measurements.forEach(um => {
|
|
const isBushelType =
|
|
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIDAR ||
|
|
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE ||
|
|
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIBRACART;
|
|
|
|
if (!isBushelType) return;
|
|
|
|
um.timestamps.forEach((ts, i) => {
|
|
const valueArray = um.values[i];
|
|
if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) return;
|
|
entries.push({
|
|
timestamp: moment(ts),
|
|
bushels: valueArray.values[0],
|
|
});
|
|
});
|
|
});
|
|
|
|
return entries.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
|
|
}
|
|
|
|
function bushelsAtTime(
|
|
history: BushelEntry[],
|
|
checkpointTime: Moment
|
|
): number | undefined {
|
|
if (history.length === 0) return undefined;
|
|
const t = checkpointTime.valueOf();
|
|
let lo = 0;
|
|
let hi = history.length - 1;
|
|
let result: number | undefined = undefined;
|
|
while (lo <= hi) {
|
|
const mid = (lo + hi) >>> 1;
|
|
if (history[mid].timestamp.valueOf() <= t) {
|
|
result = history[mid].bushels;
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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`.
|
|
*/
|
|
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
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* 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));
|
|
|
|
// Clear template values so empty checkpoints don't inherit live data
|
|
cable.celcius = [];
|
|
cable.relativeHumidity = [];
|
|
cable.moisture = [];
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
cableTemplates: pond.GrainCable[],
|
|
fanTemplates: pond.BinFan[],
|
|
heaterTemplates: pond.BinHeater[],
|
|
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
|
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
|
heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
|
/** Sorted top-node history per cable key. Pass an empty Map when not needed. */
|
|
topNodeHistories: Map<string, TopNodeHistoryEntry[]>,
|
|
/** Sorted bushel history derived from bin object measurements. */
|
|
bushelHistory: BushelEntry[],
|
|
/** Fallback bushel count used when no measurement precedes a checkpoint. */
|
|
fallbackBushels: number,
|
|
start: Moment,
|
|
end: Moment,
|
|
count: number = CHECKPOINT_COUNT
|
|
): StatusCheckpoint[] {
|
|
const times = buildCheckpointTimes(start, end, count);
|
|
console.log(times);
|
|
|
|
// --- cables ---
|
|
const cableKeys = cableTemplates.map(c => c.key);
|
|
const cableBuckets = assignMeasurementsToCheckpoints(
|
|
cableKeys,
|
|
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<string, CableReadingsAtCheckpoint>();
|
|
cableKeys.forEach(key => carriedByCable.set(key, {}));
|
|
|
|
const carriedByFan = new Map<string, BooleanStateAtCheckpoint>();
|
|
fanKeys.forEach(key => carriedByFan.set(key, {}));
|
|
|
|
const carriedByHeater = new Map<string, BooleanStateAtCheckpoint>();
|
|
heaterKeys.forEach(key => carriedByHeater.set(key, {}));
|
|
|
|
return cableBuckets.map((cableBucket, i) => {
|
|
const time = times[i];
|
|
|
|
// Build cables
|
|
const cables = cableTemplates.map(template => {
|
|
const key = template.key;
|
|
const bucketReadings = cableBucket.get(key) ?? {};
|
|
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
|
|
carriedByCable.set(key, merged);
|
|
const cable = buildGrainCableFromReadings(template, merged, time);
|
|
|
|
// Apply the most recent top-node setting that was in effect at this checkpoint time.
|
|
// Falls back to the template's current top_node when no history entry precedes this time.
|
|
const history = topNodeHistories.get(key);
|
|
if (history) {
|
|
const tn = topNodeAtTime(history, time);
|
|
if (tn !== undefined) {
|
|
cable.topNode = tn;
|
|
}
|
|
}
|
|
|
|
return cable;
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// Resolve bushels: binary search gives the most recent recorded value at or before this
|
|
// checkpoint (carry-forward is implicit in the search). Falls back to the current live
|
|
// status bushels when the playback range predates all recorded measurements.
|
|
const statusBushels = bushelsAtTime(bushelHistory, time) ?? fallbackBushels;
|
|
|
|
return { time, cables, fans, heaters, timeString: time.toISOString(), statusBushels };
|
|
}).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.
|
|
* @param start - The start date
|
|
* @param end - The end date
|
|
* @returns The number of checkpoints to create
|
|
*/
|
|
function checkpointCountFromRange(start: Moment, end: Moment): number {
|
|
const hours = end.diff(start, 'hours');
|
|
return Math.max(1, Math.floor(hours / 6) + 1);
|
|
}
|
|
|
|
/**
|
|
* 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 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();
|
|
const binAPI = useBinAPI();
|
|
const [dateSelect, setDateSelect] = useState<number>(0);
|
|
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
|
|
const [endDate, setEndDate] = useState<Moment>(moment);
|
|
const [dateRangeDialog, setDateRangeDialog] = useState(false);
|
|
const [tempStartDate, setTempStartDate] = useState<Moment>(moment().subtract(1, 'week'));
|
|
const [tempEndDate, setTempEndDate] = useState<Moment>(moment());
|
|
const [currentCheckpointTime, setCurrentCheckpointTime] = useState<Moment | undefined>(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;
|
|
const [isPlayingState, setIsPlayingState] = useState(false);
|
|
const stopPlayer = () => { isPlaying.current = false; };
|
|
const originalBin = useRef<Bin | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isPlayingState) return;
|
|
|
|
const fps = 30;
|
|
const interval = 1000 / fps;
|
|
const stepDuration = PLAYBACK_INTERVAL;
|
|
|
|
const timer = setInterval(() => {
|
|
if (!isPlayingState) {
|
|
clearInterval(timer);
|
|
setDisplayProgress(0);
|
|
return;
|
|
}
|
|
const total = totalCheckpoints.current;
|
|
if (total === 0) return;
|
|
|
|
const idx = currentCheckpointIndex.current;
|
|
const elapsed = Date.now() - checkpointStartTime.current;
|
|
const stepProgress = Math.min(elapsed / stepDuration, 1);
|
|
const raw = total <= 1 ? 100 : ((idx + stepProgress) / (total - 1)) * 100;
|
|
setDisplayProgress(Math.min(raw, 100));
|
|
}, interval);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [isPlayingState]);
|
|
|
|
/**
|
|
* 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);
|
|
isPlaying.current = true;
|
|
setIsPlayingState(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);
|
|
});
|
|
|
|
// Cable top-node history requests — one per cable, covering the full playback range.
|
|
const cableHistoryRequests = bin.status.grainCables.map(cable => {
|
|
const deviceID = componentDevices.get(cable.key) ?? 0;
|
|
return componentAPI.listHistoryBetween(
|
|
deviceID,
|
|
cable.key,
|
|
startDate.toISOString(),
|
|
endDate.toISOString()
|
|
);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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 binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc');
|
|
|
|
const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([
|
|
Promise.all(cableSampleRequests),
|
|
Promise.all(fanSampleRequests),
|
|
Promise.all(heaterSampleRequests),
|
|
Promise.all(cableHistoryRequests),
|
|
binMeasurementsRequest,
|
|
]);
|
|
|
|
// Build a per-cable sorted top-node history map for use during checkpoint construction.
|
|
const topNodeHistories = new Map<string, TopNodeHistoryEntry[]>();
|
|
bin.status.grainCables.forEach((cable, i) => {
|
|
const historyResp = cableHistoryResponses[i];
|
|
const entries = buildTopNodeHistory(historyResp?.data?.history ?? []);
|
|
topNodeHistories.set(cable.key, entries);
|
|
});
|
|
|
|
// Build sorted bushel history from bin object measurements.
|
|
const bushelHistory = buildBushelHistory(binMeasurementsResponse.data);
|
|
|
|
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),
|
|
topNodeHistories,
|
|
bushelHistory,
|
|
bin.status.grainBushels,
|
|
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;
|
|
newBin.status.grainBushels = checkpoints[i].statusBushels;
|
|
|
|
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 (
|
|
<ResponsiveDialog
|
|
open={dateRangeDialog}
|
|
onClose={() => setDateRangeDialog(false)}
|
|
aria-labelledby="date-range-dialog">
|
|
<DialogContent>
|
|
<TextField
|
|
fullWidth
|
|
margin="normal"
|
|
type="date"
|
|
label="Start Date"
|
|
value={tempStartDate.format("YYYY-MM-DD")}
|
|
onChange={e => {
|
|
setTempStartDate(moment(e.target.value));
|
|
}}
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
margin="normal"
|
|
type="date"
|
|
label="End Date"
|
|
value={tempEndDate.format("YYYY-MM-DD")}
|
|
onChange={e => {
|
|
setTempEndDate(moment(e.target.value));
|
|
}}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setDateRangeDialog(false)} color="primary">
|
|
Close
|
|
</Button>
|
|
<Button onClick={() => {
|
|
const now = moment();
|
|
setStartDate(tempStartDate.clone().set({
|
|
hour: now.hour(),
|
|
minute: now.minute(),
|
|
second: now.second()
|
|
}));
|
|
setEndDate(tempEndDate.clone().set({
|
|
hour: now.hour(),
|
|
minute: now.minute(),
|
|
second: now.second()
|
|
}));
|
|
setDateRangeDialog(false);
|
|
}} color="primary">
|
|
Submit
|
|
</Button>
|
|
</DialogActions>
|
|
</ResponsiveDialog>
|
|
);
|
|
};
|
|
|
|
const dateSelector = () => {
|
|
return (
|
|
<Box display="flex" flexDirection="row" justifyContent="space-between">
|
|
<FormControl fullWidth>
|
|
<Select
|
|
value={dateSelect}
|
|
sx={{
|
|
fontSize: 12,
|
|
borderRadius: 2,
|
|
border: "1px solid",
|
|
borderColor: grey[800],
|
|
'& .MuiSelect-select': {
|
|
py: 1.5,
|
|
},
|
|
'& .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;
|
|
}
|
|
}}
|
|
>
|
|
<MenuItem value={0}>1 Week</MenuItem>
|
|
<MenuItem value={1}>2 Weeks</MenuItem>
|
|
<MenuItem value={2}>4 Weeks</MenuItem>
|
|
<MenuItem value={3}>Custom</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
{dateSelect === 3 && (
|
|
<Button onClick={() => setDateRangeDialog(true)} color="primary"><DateRange /></Button>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<React.Fragment>
|
|
{datePickerDialog()}
|
|
<Box display="flex" flexDirection="row" justifyContent="space-between" width="100%" gap={2} alignContent={"center"} alignItems={"center"}>
|
|
{/* start/stop button */}
|
|
<Box>
|
|
{isPlayingState ? (
|
|
<IconButton sx={{ border: "1px solid", borderRadius: "50%", padding: 1, height: isMobile ? 40 : 60, width: isMobile ? 40 : 60 }} onClick={stopPlayer}><Stop fontSize="large" /></IconButton>
|
|
) : (
|
|
<IconButton sx={{ border: "1px solid", borderRadius: "50%", padding: 1, height: isMobile ? 40 : 60, width: isMobile ? 40 : 60 }} onClick={startPlayer}><PlayArrow fontSize="large" /></IconButton>
|
|
)}
|
|
</Box>
|
|
{/* progress bar and date display */}
|
|
<Box sx={{ flexGrow: 1 }}>
|
|
<Typography fontSize={isMobile ? 15 : 17} textAlign={"center"}>{currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")}</Typography>
|
|
<LinearProgress value={displayProgress} variant="determinate" color="inherit" />
|
|
<Box display="flex" flexDirection="row" justifyContent="space-between">
|
|
<Typography variant="caption">{startDate.format("MMM D, YYYY")}</Typography>
|
|
<Typography variant="caption">{endDate.format("MMM D, YYYY")}</Typography>
|
|
</Box>
|
|
</Box>
|
|
{/* date range selector */}
|
|
<Box>
|
|
{dateSelector()}
|
|
</Box>
|
|
</Box>
|
|
</React.Fragment>
|
|
);
|
|
} |