Merge branch 'bin_player_inventory_changes' into staging_environment
This commit is contained in:
commit
5dc3a3e975
4 changed files with 199 additions and 8 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -46,7 +46,7 @@
|
||||||
"mui-tel-input": "^7.0.0",
|
"mui-tel-input": "^7.0.0",
|
||||||
"notistack": "^3.0.1",
|
"notistack": "^3.0.1",
|
||||||
"openweathermap-ts": "^1.2.10",
|
"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",
|
"query-string": "^9.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
|
|
@ -11752,7 +11752,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/protobuf-ts": {
|
"node_modules/protobuf-ts": {
|
||||||
"version": "1.0.0",
|
"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": {
|
"dependencies": {
|
||||||
"protobufjs": "^6.8.8"
|
"protobufjs": "^6.8.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Bin } from "models";
|
||||||
import moment, { Moment } from "moment";
|
import moment, { Moment } from "moment";
|
||||||
import { pond } from "protobuf-ts/pond";
|
import { pond } from "protobuf-ts/pond";
|
||||||
import { quack } from "protobuf-ts/quack";
|
import { quack } from "protobuf-ts/quack";
|
||||||
import { useComponentAPI } from "providers";
|
import { useBinAPI, useComponentAPI } from "providers";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const CHECKPOINT_COUNT = 10;
|
const CHECKPOINT_COUNT = 10;
|
||||||
|
|
@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10;
|
||||||
interface StatusCheckpoint {
|
interface StatusCheckpoint {
|
||||||
time: Moment;
|
time: Moment;
|
||||||
timeString: string;
|
timeString: string;
|
||||||
|
statusBushels: number;
|
||||||
cables: pond.GrainCable[];
|
cables: pond.GrainCable[];
|
||||||
fans: pond.BinFan[];
|
fans: pond.BinFan[];
|
||||||
heaters: pond.BinHeater[];
|
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).
|
* Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
|
||||||
* These are the playback frames — not derived from measurement data.
|
* These are the playback frames — not derived from measurement data.
|
||||||
|
|
@ -360,6 +458,12 @@ function buildStatusCheckpoints(
|
||||||
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||||
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||||
heaterMeasurementResponses: 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,
|
start: Moment,
|
||||||
end: Moment,
|
end: Moment,
|
||||||
count: number = CHECKPOINT_COUNT
|
count: number = CHECKPOINT_COUNT
|
||||||
|
|
@ -416,7 +520,19 @@ function buildStatusCheckpoints(
|
||||||
const bucketReadings = cableBucket.get(key) ?? {};
|
const bucketReadings = cableBucket.get(key) ?? {};
|
||||||
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
|
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
|
||||||
carriedByCable.set(key, merged);
|
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
|
// Build fans
|
||||||
|
|
@ -437,7 +553,12 @@ function buildStatusCheckpoints(
|
||||||
return buildBinHeaterFromState(template, merged);
|
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 =>
|
}).filter(checkpoint =>
|
||||||
// Keep checkpoints that have at least some cable data
|
// Keep checkpoints that have at least some cable data
|
||||||
checkpoint.cables.some(cable =>
|
checkpoint.cables.some(cable =>
|
||||||
|
|
@ -471,6 +592,7 @@ export default function BinPlayer(props: Props) {
|
||||||
const { bin, componentDevices, setBin } = props;
|
const { bin, componentDevices, setBin } = props;
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const componentAPI = useComponentAPI();
|
const componentAPI = useComponentAPI();
|
||||||
|
const binAPI = useBinAPI();
|
||||||
const [dateSelect, setDateSelect] = useState<number>(0);
|
const [dateSelect, setDateSelect] = useState<number>(0);
|
||||||
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
|
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
|
||||||
const [endDate, setEndDate] = useState<Moment>(moment);
|
const [endDate, setEndDate] = useState<Moment>(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);
|
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
|
// Fan requests
|
||||||
const fanSampleRequests = bin.status.fans.map(fan => {
|
const fanSampleRequests = bin.status.fans.map(fan => {
|
||||||
const deviceID = componentDevices.get(fan.key) ?? 0;
|
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);
|
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(cableSampleRequests),
|
||||||
Promise.all(fanSampleRequests),
|
Promise.all(fanSampleRequests),
|
||||||
Promise.all(heaterSampleRequests),
|
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(
|
const checkpoints = buildStatusCheckpoints(
|
||||||
bin.status.grainCables,
|
bin.status.grainCables,
|
||||||
bin.status.fans,
|
bin.status.fans,
|
||||||
|
|
@ -554,6 +702,9 @@ export default function BinPlayer(props: Props) {
|
||||||
cableResponses.map(r => r.data),
|
cableResponses.map(r => r.data),
|
||||||
fanResponses.map(r => r.data),
|
fanResponses.map(r => r.data),
|
||||||
heaterResponses.map(r => r.data),
|
heaterResponses.map(r => r.data),
|
||||||
|
topNodeHistories,
|
||||||
|
bushelHistory,
|
||||||
|
bin.status.grainBushels,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
checkpointCountFromRange(startDate, endDate)
|
checkpointCountFromRange(startDate, endDate)
|
||||||
|
|
@ -574,6 +725,7 @@ export default function BinPlayer(props: Props) {
|
||||||
newBin.status.grainCables = checkpoints[i].cables;
|
newBin.status.grainCables = checkpoints[i].cables;
|
||||||
newBin.status.fans = checkpoints[i].fans;
|
newBin.status.fans = checkpoints[i].fans;
|
||||||
newBin.status.heaters = checkpoints[i].heaters;
|
newBin.status.heaters = checkpoints[i].heaters;
|
||||||
|
newBin.status.grainBushels = checkpoints[i].statusBushels;
|
||||||
|
|
||||||
setBin(newBin);
|
setBin(newBin);
|
||||||
setCurrentCheckpointTime(checkpoints[i].time);
|
setCurrentCheckpointTime(checkpoints[i].time);
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,7 @@ export default function BinTableView(props: Props) {
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{ background: theme.palette.background.default}}
|
sx={{ background: theme.palette.background.default, color: theme.palette.text.primary}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenExpandedCables(true)
|
setOpenExpandedCables(true)
|
||||||
}}>
|
}}>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,14 @@ export interface IComponentAPIContext {
|
||||||
keys?: string[],
|
keys?: string[],
|
||||||
types?: string[]
|
types?: string[]
|
||||||
) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>;
|
) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>;
|
||||||
|
listHistoryBetween: (
|
||||||
|
deviceId: string | number,
|
||||||
|
componentKey: string,
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
keys?: string[],
|
||||||
|
types?: string[]
|
||||||
|
) => Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>;
|
||||||
// Old measuremnt structure functions, no longer used in codebase
|
// Old measuremnt structure functions, no longer used in codebase
|
||||||
// listMeasurements: (
|
// listMeasurements: (
|
||||||
// device: number,
|
// device: number,
|
||||||
|
|
@ -391,6 +399,36 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listHistoryBetween = (
|
||||||
|
deviceId: string | number,
|
||||||
|
componentKey: string,
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
keys?: string[],
|
||||||
|
types?: string[]
|
||||||
|
) => {
|
||||||
|
let url = pondURL(
|
||||||
|
"/devices/" +
|
||||||
|
deviceId +
|
||||||
|
"/components/" +
|
||||||
|
componentKey +
|
||||||
|
"/historyBetween?start=" +
|
||||||
|
start +
|
||||||
|
"&end=" +
|
||||||
|
end +
|
||||||
|
(keys ? "&keys=" + keys : "&keys=" + [deviceId.toString()]) +
|
||||||
|
(types ? "&types=" + types : "&types=" + ["device"])
|
||||||
|
)
|
||||||
|
return new Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>((resolve,reject) => {
|
||||||
|
get<pond.ListComponentHistoryBetweenResponse>(url).then(resp => {
|
||||||
|
resp.data = pond.ListComponentHistoryBetweenResponse.fromObject(resp.data)
|
||||||
|
return resolve(resp)
|
||||||
|
}).catch(err => {
|
||||||
|
return reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// const listMeasurements = (
|
// const listMeasurements = (
|
||||||
// device: number,
|
// device: number,
|
||||||
// component: string,
|
// component: string,
|
||||||
|
|
@ -596,6 +634,7 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
|
||||||
listForObject: listComponentsForObject,
|
listForObject: listComponentsForObject,
|
||||||
listComponentCardData: listComponentCardData,
|
listComponentCardData: listComponentCardData,
|
||||||
listHistory,
|
listHistory,
|
||||||
|
listHistoryBetween,
|
||||||
//listMeasurements: listMeasurements,
|
//listMeasurements: listMeasurements,
|
||||||
//sampleMeasurements: sampleMeasurements,
|
//sampleMeasurements: sampleMeasurements,
|
||||||
sampleUnitMeasurements,
|
sampleUnitMeasurements,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue