diff --git a/package-lock.json b/package-lock.json index 3878ae5..19352f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11752,7 +11752,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#42f72fa7fb838b7771e6c847faaa192e93f42da7", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#100126cc7f3cdf18f68af6372e5db4113db012b7", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index 88bc45c..364d5af 100644 --- a/src/bin/BinPlayer.tsx +++ b/src/bin/BinPlayer.tsx @@ -8,7 +8,7 @@ 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 { useBinAPI, useComponentAPI } from "providers"; import React, { useEffect, useRef, useState } from "react"; const CHECKPOINT_COUNT = 10; @@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10; interface StatusCheckpoint { time: Moment; timeString: string; + statusBushels: number; cables: pond.GrainCable[]; fans: pond.BinFan[]; heaters: pond.BinHeater[]; @@ -90,6 +91,103 @@ function upsertReading( } } +/** + * 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. @@ -360,6 +458,12 @@ function buildStatusCheckpoints( 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, + /** 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 @@ -416,7 +520,19 @@ function buildStatusCheckpoints( const bucketReadings = cableBucket.get(key) ?? {}; const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); carriedByCable.set(key, merged); - return buildGrainCableFromReadings(template, merged, time); + 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 @@ -437,7 +553,12 @@ function buildStatusCheckpoints( return buildBinHeaterFromState(template, merged); }); - return { time, cables, fans, heaters, timeString: time.toISOString() }; + // 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 => @@ -471,6 +592,7 @@ export default function BinPlayer(props: Props) { const { bin, componentDevices, setBin } = props; const isMobile = useMobile(); const componentAPI = useComponentAPI(); + const binAPI = useBinAPI(); const [dateSelect, setDateSelect] = useState(0); const [startDate, setStartDate] = useState(moment().subtract(1, 'week')); const [endDate, setEndDate] = useState(moment); @@ -529,6 +651,17 @@ export default function BinPlayer(props: Props) { 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; @@ -541,12 +674,27 @@ export default function BinPlayer(props: Props) { return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); - const [cableResponses, fanResponses, heaterResponses] = await Promise.all([ + 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(); + 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, @@ -554,6 +702,9 @@ export default function BinPlayer(props: Props) { 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) @@ -574,6 +725,7 @@ export default function BinPlayer(props: Props) { 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); @@ -715,4 +867,4 @@ export default function BinPlayer(props: Props) { ); -} +} \ No newline at end of file diff --git a/src/bin/binTableView.tsx b/src/bin/binTableView.tsx index 0659335..e11132e 100644 --- a/src/bin/binTableView.tsx +++ b/src/bin/binTableView.tsx @@ -312,7 +312,7 @@ export default function BinTableView(props: Props) {