frontend/src/bin/BinStorageConditions.tsx

762 lines
23 KiB
TypeScript

import {
Box,
Button,
Card,
darken,
Grid2 as Grid,
InputAdornment,
lighten,
Slider,
TextField,
Theme,
Tooltip,
Typography
} from "@mui/material";
import { CheckCircleOutline, ImportExport } from "@mui/icons-material";
import Warning from "@mui/icons-material/Warning";
import HumidityIcon from "component/HumidityIcon";
import TemperatureIcon from "component/TemperatureIcon";
import { cloneDeep } from "lodash";
import { Bin, Component } from "models";
import { GrainCable } from "models/GrainCable";
import { UnitMeasurement } from "models/UnitMeasurement";
import Co2Icon from "products/CommonIcons/co2Icon";
import { pond } from "protobuf-ts/pond";
import { useBinAPI, useGlobalState, useSnackbar } from "providers";
import React, { useEffect, useState } from "react";
import { avg, getTemperatureUnit } from "utils";
import { makeStyles } from "@mui/styles";
import { Mark } from "@mui/material/Slider/useSlider.types";
const useStyles = makeStyles((theme: Theme) => {
return ({
card: {
padding: 10,
paddingLeft: 15,
paddingRight: 15
},
displayBG: {
marginTop: 10,
background: darken(theme.palette.background.default, 0.05),
borderRadius: 5,
padding: 10
},
slider: {
zIndex: 1,
"&:hover": {
zIndex: 3
}
},
markContainer: {
zIndex: 2
},
arrowDown: {
width: 0,
height: 0,
borderLeft: "10px solid transparent",
borderRight: "10px solid transparent",
borderTop: "10px solid yellow"
},
co2Box: {
background: lighten(theme.palette.background.default, 0.2),
borderRadius: 10,
padding: 10,
margin: 10,
marginLeft: 0,
marginRight: 0
},
low: {
fontWeight: 650,
color: "#0575E6"
},
target: {
fontWeight: 650,
color: "green"
},
high: {
fontWeight: 650,
color: "#c42605"
},
sliderRoot: {},
sliderThumb: {
height: 15,
width: 15,
marginTop: 0,
marginLeft: 0,
backgroundColor: "yellow"
},
sliderTrack: {
height: 3,
backgroundColor: "white"
},
sliderRail: {
height: 3,
backgroundColor: "white"
},
sliderValLabel: {
left: -20,
top: 40,
background: "transparent",
"& *": {
color: "#fff"
}
},
sliderMark: {
visibility: "hidden"
},
sliderMarked: {
marginTop: 25,
marginBottom: 0
},
sliderMarkLabel: {
top: -25,
left: -10
}
});
});
interface Props {
bin: Bin;
headspaceCO2: Component[];
cables: GrainCable[];
}
interface NodeCounts {
below: number;
onTarget: number;
above: number;
total: number;
}
export default function BinStorageConditions(props: Props) {
const classes = useStyles();
const binAPI = useBinAPI();
const { openSnack } = useSnackbar();
const { bin, headspaceCO2, cables } = props;
const [{as}] = useGlobalState()
const [sliderTemps, setSliderTemps] = useState<number[]>([-40, 40]);
//boolean that if a cable is missing its filled to display an icon or something to let the user know not all of the cables have their fill set
const [missingTopNodeWarning, setMissingTopNodeWarning] = useState(false);
const [targetTemp, setTargetTemp] = useState("");
const [targetEMC, setTargetEMC] = useState("");
const [emcDeviation, setEmcDeviation] = useState("");
const [averageTemp, setAverageTemp] = useState<number | undefined>(10);
const [averageEMC, setAverageEMC] = useState<number | undefined>(10);
//the number of nodes in the grain that fall into each category
const [tempTargets, setTempTargets] = useState<NodeCounts>({
below: 0,
onTarget: 0,
above: 0,
total: 0
});
const [emcTargets, setEmcTargets] = useState<NodeCounts>({
below: 0,
onTarget: 0,
above: 0,
total: 0
});
useEffect(() => {
setSliderTemps([bin.settings.lowTemp, bin.settings.highTemp]);
let cableTempAvgs: number[] = [];
let tempCounts: NodeCounts = {
below: 0,
onTarget: 0,
above: 0,
total: 0
};
let emcCounts: NodeCounts = {
below: 0,
onTarget: 0,
above: 0,
total: 0
};
let cableEMCAvgs: number[] = [];
cables.forEach(cable => {
//if a cable is missing the top node being set
if (cable.topNode === 0) {
setMissingTopNodeWarning(true);
} else {
//with the way the measurements are stored the node closest to the device (highest on the cable) is the first position of the array
//so we will need to flip the array so that node 1 is the beginning of the array
let temps = cloneDeep(cable.temperatures).reverse();
let emcs = cloneDeep(cable.grainMoistures).reverse();
//only use the nodes up to the top node
let nodeTemps: number[] = [];
temps.forEach((temp, i) => {
if (i < cable.topNode) {
nodeTemps.push(temp);
tempCounts.total++;
if (temp > bin.settings.highTemp) {
tempCounts.above++;
} else if (temp < bin.settings.lowTemp) {
tempCounts.below++;
} else {
tempCounts.onTarget++;
}
}
});
let nodeEMCs: number[] = [];
emcs.forEach((emc, i) => {
if (bin.settings.inventory?.targetMoisture) {
if (i < cable.topNode) {
nodeEMCs.push(emc);
emcCounts.total++;
if (
emc >
bin.settings.inventory.targetMoisture +
bin.settings.inventory.moistureTargetDeviation
) {
emcCounts.above++;
} else if (
emc <
bin.settings.inventory.targetMoisture -
bin.settings.inventory.moistureTargetDeviation
) {
emcCounts.below++;
} else {
emcCounts.onTarget++;
}
}
}
});
cableTempAvgs.push(avg(nodeTemps));
cableEMCAvgs.push(avg(nodeEMCs));
}
});
if (cableTempAvgs.length > 0) {
setAverageTemp(avg(cableTempAvgs));
}
setTempTargets(tempCounts);
let tempVal = bin.settings.inventory?.targetTemperature ?? 0;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempVal = tempVal * 1.8 + 32;
}
setTargetTemp(tempVal.toFixed(1));
if (cableEMCAvgs.length > 0) {
setAverageEMC(avg(cableEMCAvgs));
}
setEmcTargets(emcCounts);
setTargetEMC(bin.settings.inventory?.targetMoisture.toFixed(1) ?? "");
setEmcDeviation(bin.settings.inventory?.moistureTargetDeviation.toFixed(1) ?? "");
}, [bin, cables]);
//useEffect that watches for changes in the target emc and deviation to update the targets
useEffect(() => {
let target = parseFloat(targetEMC);
let deviation = parseFloat(emcDeviation);
if (!isNaN(target) && !isNaN(deviation)) {
let emcCounts: NodeCounts = {
below: 0,
onTarget: 0,
above: 0,
total: 0
};
cables.forEach(cable => {
let emcs = cloneDeep(cable.grainMoistures).reverse();
//let nodeEMCs: number[] = [];
emcs.forEach((emc, i) => {
if (bin.settings.inventory?.targetMoisture) {
if (i < cable.topNode) {
//nodeEMCs.push(emc);
emcCounts.total++;
if (emc > target + deviation) {
emcCounts.above++;
} else if (emc < target - deviation) {
emcCounts.below++;
} else {
emcCounts.onTarget++;
}
}
}
});
});
setEmcTargets(emcCounts);
}
}, [targetEMC, emcDeviation, bin, cables]);
const isErrors = () => {
let errors = false;
if (
isNaN(parseFloat(targetTemp)) ||
isNaN(parseFloat(targetEMC)) ||
isNaN(parseFloat(emcDeviation))
) {
errors = true;
}
return errors;
};
const updateBinThresholds = () => {
//update the bin settings with the new thresholds
let settings = bin.settings;
settings.lowTemp = sliderTemps[0];
settings.highTemp = sliderTemps[1];
if (settings.inventory) {
let tempVal = parseFloat(targetTemp);
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempVal = Math.fround(((tempVal - 32) * 5) / 9);
}
settings.inventory.targetTemperature = tempVal;
settings.inventory.targetMoisture = parseFloat(targetEMC);
settings.inventory.moistureTargetDeviation = parseFloat(emcDeviation);
}
binAPI
.updateBin(bin.key(), settings, as)
.then(_resp => {
openSnack("Updated bin thresholds");
})
.catch(_err => {
openSnack("Failed to update bin thresholds");
});
};
const updateSliderTemps = (newVals: number[]) => {
//the slider always returns the array with the lower value first
setSliderTemps(newVals);
//re-calc the node counts
let tempCounts: NodeCounts = {
below: 0,
onTarget: 0,
above: 0,
total: 0
};
cables.forEach(cable => {
let temps = cloneDeep(cable.temperatures).reverse();
let nodeTemps: number[] = [];
temps.forEach((temp, i) => {
if (i < cable.topNode) {
nodeTemps.push(temp);
tempCounts.total++;
if (temp > newVals[1]) {
tempCounts.above++;
} else if (temp < newVals[0]) {
tempCounts.below++;
} else {
tempCounts.onTarget++;
}
}
});
});
setTempTargets(tempCounts);
};
const customMark = (val: string, sub: string) => {
return (
<Box className={classes.markContainer}>
<Grid container direction="column" alignContent="center" alignItems="center">
<Grid>
<Typography style={{ fontSize: 15, fontWeight: 650, lineHeight: 1 }}>{val}</Typography>
</Grid>
<Grid>
<Typography style={{ fontSize: 8 }}>{sub}</Typography>
</Grid>
<Grid>
<Box className={classes.arrowDown} />
</Grid>
</Grid>
</Box>
);
};
//this will display at all times
const tempDisplay = () => {
// console.log(tempTargets)
let sliderEdge = 40;
let mark: Mark[] = [];
if (averageTemp) {
let temp = averageTemp;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = temp * 1.8 + 32;
}
mark = [
{
label: customMark(
temp.toFixed(1) +
(getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "°C"),
"AVG"
),
value: averageTemp
}
];
}
return (
<Box className={classes.displayBG}>
<Grid
container
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Grid size={{ xs: 8 }}>
<Box display="flex" alignContent="center" alignItems="center">
<TemperatureIcon />
<Typography noWrap style={{ fontWeight: 650 }}>
Target Temp =
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 4 }}>
<TextField
variant="outlined"
margin="dense"
value={targetTemp}
error={isNaN(parseFloat(targetTemp))}
helperText={isNaN(parseFloat(targetTemp)) && "Must be a valid number"}
onChange={e => {
setTargetTemp(e.target.value);
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "°C"}
</InputAdornment>
)
}}
/>
</Grid>
</Grid>
<Slider
className={classes.slider}
classes={{
root: classes.sliderRoot,
rail: classes.sliderRail,
track: classes.sliderTrack,
thumb: classes.sliderThumb,
valueLabel: classes.sliderValLabel,
marked: classes.sliderMarked,
mark: classes.sliderMark,
markLabel: classes.sliderMarkLabel
}}
marks={mark}
step={0.1}
value={sliderTemps}
min={-sliderEdge}
max={sliderEdge}
valueLabelDisplay="on"
valueLabelFormat={value => {
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
return ((value * 9) / 5 + 32).toFixed(1) + "°F";
}
return value.toFixed(1) + "°C";
}}
onChange={(_, newVals) => {
updateSliderTemps(newVals as number[]);
}}
/>
{tempTargets.total > 0 && (
<Box style={{ marginLeft: 10, marginRight: 10, marginTop: 5 }}>
<Grid container direction="row" justifyContent="space-between">
<Grid>
<Typography className={classes.low} align="center">
{((tempTargets.below / tempTargets.total) * 100).toFixed(2)}%
</Typography>
<Typography align="center">Below</Typography>
</Grid>
<Grid>
<Typography className={classes.target} align="center">
{((tempTargets.onTarget / tempTargets.total) * 100).toFixed(2)}%
</Typography>
<Typography align="center">On Target</Typography>
</Grid>
<Grid>
<Typography className={classes.high} align="center">
{((tempTargets.above / tempTargets.total) * 100).toFixed(2)}%
</Typography>
<Typography align="center">Above</Typography>
</Grid>
</Grid>
</Box>
)}
</Box>
);
};
//this will only display if the bin has the moisture target set
const moistureDisplay = () => {
const targetVal = parseFloat(targetEMC);
const devVal = parseFloat(emcDeviation);
let low = targetVal - devVal;
let high = targetVal + devVal;
const min = 0;
const max = 25;
if (isNaN(low) || isNaN(high)) {
low = 0;
high = 0;
}
let mark: Mark[] = [];
if (averageEMC) {
mark = [
{
label: customMark(averageEMC.toFixed(1) + "%", "AVG"),
value: averageEMC
}
];
}
return (
<Box className={classes.displayBG}>
<Grid
container
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Grid size={{ xs: 6 }}>
<Box display="flex" alignContent="center" alignItems="center">
<Box style={{ width: 24, paddingLeft: 3 }}>
<HumidityIcon height={24} width={15} />
</Box>
<Typography noWrap style={{ fontWeight: 650 }}>
Target EMC =
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 4 }}>
<TextField
variant="outlined"
margin="dense"
value={targetEMC}
error={isNaN(parseFloat(targetEMC))}
helperText={isNaN(parseFloat(targetEMC)) && "Must be a valid number"}
onChange={e => {
setTargetEMC(e.target.value);
}}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>
}}
/>
</Grid>
</Grid>
<Grid
container
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Grid size={{ xs: 6 }}>
<Box display="flex" alignContent="center" alignItems="center">
<ImportExport />
<Typography noWrap style={{ fontWeight: 650 }}>
Deviation =
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 4 }}>
<TextField
variant="outlined"
margin="dense"
value={emcDeviation}
error={isNaN(parseFloat(emcDeviation))}
helperText={isNaN(parseFloat(emcDeviation)) && "Must be a valid number"}
onChange={e => {
setEmcDeviation(e.target.value);
}}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>
}}
/>
</Grid>
</Grid>
<Slider
style={{ cursor: "default" }}
marks={mark}
classes={{
root: classes.sliderRoot,
rail: classes.sliderRail,
track: classes.sliderTrack,
thumb: classes.sliderThumb,
valueLabel: classes.sliderValLabel,
marked: classes.sliderMarked,
mark: classes.sliderMark,
markLabel: classes.sliderMarkLabel
}}
valueLabelDisplay="on"
valueLabelFormat={value => {
return value.toFixed(1) + "%";
}}
step={0.5}
min={min}
max={max}
value={[low, high]}
/>
{emcTargets.total > 0 && (
<Box style={{ marginLeft: 10, marginRight: 10, marginTop: 5 }}>
<Grid container direction="row" justifyContent="space-between">
<Grid>
<Typography className={classes.low} align="center">
{((emcTargets.below / emcTargets.total) * 100).toFixed(2)}%
</Typography>
<Typography align="center">Below</Typography>
</Grid>
<Grid>
<Typography className={classes.target} align="center">
{((emcTargets.onTarget / emcTargets.total) * 100).toFixed(2)}%
</Typography>
<Typography align="center">On Target</Typography>
</Grid>
<Grid>
<Typography className={classes.high} align="center">
{((emcTargets.above / emcTargets.total) * 100).toFixed(2)}%
</Typography>
<Typography align="center">Above</Typography>
</Grid>
</Grid>
</Box>
)}
</Box>
);
};
//this will only appear when there is a CO2 sensor
const co2Display = () => {
if (headspaceCO2.length > 0) {
return (
<React.Fragment>
<Box className={classes.displayBG}>
<Box display="flex">
<Co2Icon size={24} />
<Typography style={{ fontWeight: 650, marginLeft: 5 }}>
CO2 Spoilage Detection
</Typography>
</Box>
{headspaceCO2.map(co2 => {
let measurement = UnitMeasurement.any(co2.status.lastGoodMeasurement[0] ?? UnitMeasurement.create());
let reading: number = 0;
if (measurement.values[0] && measurement.values[0].values[0]) {
reading = measurement.values[0].values[0];
}
let mark: Mark[] = [];
if (reading > 0) {
mark = [
{
label: customMark(reading + "ppm", "last"),
value: reading
}
];
}
return (
<Grid
key={co2.key()}
container
direction="row"
alignContent="center"
alignItems="center">
<Grid size={{ xs: 4 }}>
<Box className={classes.co2Box}>
<Grid container direction="column" alignContent="center" alignItems="center">
<Grid >
<Typography style={{ fontSize: 15, textAlign: "center" }}>
Current CO2
</Typography>
</Grid>
<Grid >
<Typography
style={{
fontSize: 15,
fontWeight: 650,
textAlign: "center",
color: measurement.colour
}}>
{reading} ppm
</Typography>
</Grid>
<Grid >
<CheckCircleOutline style={{ color: reading < 1100 ? "green" : "red" }} />
</Grid>
</Grid>
</Box>
</Grid>
<Grid size={{ xs: 8 }}>
<Box marginLeft={2}>
<Box>
<Slider
style={{ cursor: "default" }}
min={200}
max={1300}
value={[400, 1100]}
marks={mark}
classes={{
root: classes.sliderRoot,
rail: classes.sliderRail,
track: classes.sliderTrack,
thumb: classes.sliderThumb,
valueLabel: classes.sliderValLabel,
marked: classes.sliderMarked,
mark: classes.sliderMark,
markLabel: classes.sliderMarkLabel
}}
valueLabelDisplay="on"
valueLabelFormat={value => {
return value + "ppm";
}}
/>
</Box>
</Box>
</Grid>
</Grid>
);
})}
</Box>
</React.Fragment>
);
}
return;
};
//made up of the co2, moisture and temp displays
const storageConditions = () => {
return (
<Box>
<Box display="flex" justifyContent={"space-between"}>
<Typography style={{ fontWeight: 650 }}>Storage Conditions</Typography>
{missingTopNodeWarning && (
<Tooltip
title={`
One or more cables do not have their fill level set.
Check to make sure all top nodes are set for your bin cables
`}>
<Warning />
</Tooltip>
)}
</Box>
{tempDisplay()}
{moistureDisplay()}
{co2Display()}
<Box display="flex" marginTop={1} justifyContent="center">
<Button
variant="contained"
color="primary"
disabled={isErrors()}
onClick={() => {
updateBinThresholds();
}}>
Update Thresholds
</Button>
</Box>
</Box>
);
};
return (
<Card raised className={classes.card}>
{storageConditions()}
</Card>
);
}