diff --git a/backend/api/__init__.py b/backend/api/__init__.py index 57588aa7..ecbeba25 100644 --- a/backend/api/__init__.py +++ b/backend/api/__init__.py @@ -170,6 +170,7 @@ def handle_unsubscribe_cells(data): from .resources.cell_tags import CellTags, CellTagDetail, CellsByTag from .resources.cell_users import CellUsers, CellUserDetail, CellByUser, CellShare from .resources.logger import Logger + from .resources.cell_sensors import CellSensors from .auth.routes import auth @@ -183,6 +184,7 @@ def handle_unsubscribe_cells(data): api.add_resource(SensorData, "/sensor/") api.add_resource(SensorData_Json, "/sensor_json/") api.add_resource(DataAvailability, "/data-availability/") + api.add_resource(CellSensors, "/cell-sensors/") api.add_resource(Session_r, "/session") api.add_resource(User_Data, "/user") api.add_resource(Status, "/status/") diff --git a/backend/api/resources/cell_sensors.py b/backend/api/resources/cell_sensors.py new file mode 100644 index 00000000..ae888507 --- /dev/null +++ b/backend/api/resources/cell_sensors.py @@ -0,0 +1,42 @@ +from flask import request +from flask_restful import Resource +from ..models.sensor import Sensor + + +class CellSensors(Resource): + def get(self): + """Get distinct sensor names that exist for specified cells + + Query Parameters: + - cell_ids: Comma-separated list of cell IDs + + Returns: + - Dict mapping cell_id (string) to list of sensor names that have data + """ + cell_ids_param = request.args.get("cell_ids") + + if cell_ids_param is None: + return {"error": "cell_ids parameter is required"}, 400 + + try: + cell_ids = [ + int(id.strip()) for id in cell_ids_param.split(",") if id.strip() + ] + except ValueError: + return {"error": "Invalid cell_ids format"}, 400 + + if not cell_ids: + return {"error": "At least one valid cell_id is required"}, 400 + + rows = ( + Sensor.query.filter(Sensor.cell_id.in_(cell_ids)) + .with_entities(Sensor.cell_id, Sensor.name) + .distinct() + .all() + ) + + result = {} + for cell_id, name in rows: + result.setdefault(str(cell_id), []).append(name) + + return result, 200 diff --git a/backend/api/resources/sensor_data.py b/backend/api/resources/sensor_data.py index 8b524a5c..85575e63 100644 --- a/backend/api/resources/sensor_data.py +++ b/backend/api/resources/sensor_data.py @@ -41,14 +41,42 @@ class SensorData(Resource): get_sensor_data_schema = GetSensorDataSchema() def get(self): - """Gets specified sensor data""" + """Gets specified sensor data + + Supports two modes: + - Single cell: pass cellId (int) — returns sensor data object directly + - Batch cells: pass cellIds (comma-separated ints) — returns {cell_id: data_obj} + """ # get args v_args = self.get_sensor_data_schema.load(request.args) stream = v_args.get("stream", False) resample = v_args.get("resample", "hour") - # get data + cell_ids_raw = v_args.get("cellIds") + if cell_ids_raw: + # Batch mode: return data for all requested cells in one response + try: + cell_ids = [ + int(x.strip()) for x in cell_ids_raw.split(",") if x.strip() + ] + except ValueError: + return {"error": "Invalid cellIds format"}, 400 + + result = {} + for cid in cell_ids: + result[str(cid)] = Sensor.get_sensor_data_obj( + name=v_args["name"], + cell_id=cid, + measurement=v_args["measurement"], + resample=resample, + start_time=v_args.get("startTime"), + end_time=v_args.get("endTime"), + stream=stream, + ) + return jsonify(result) + + # Single cell mode (existing behaviour) sensor_data_obj = Sensor.get_sensor_data_obj( name=v_args["name"], cell_id=v_args["cellId"], diff --git a/backend/api/schemas/get_sensor_data_schema.py b/backend/api/schemas/get_sensor_data_schema.py index 2c9590da..1fed7557 100644 --- a/backend/api/schemas/get_sensor_data_schema.py +++ b/backend/api/schemas/get_sensor_data_schema.py @@ -4,7 +4,8 @@ class GetSensorDataSchema(ma.SQLAlchemySchema): """validates get request for sensor data""" - cellId = ma.Int() + cellId = ma.Int(required=False) + cellIds = ma.String(required=False, load_default=None) name = ma.String() measurement = ma.String() resample = ma.String(required=False) diff --git a/frontend/src/pages/dashboard/Dashboard.jsx b/frontend/src/pages/dashboard/Dashboard.jsx index 8f1ec08a..6f11eccb 100644 --- a/frontend/src/pages/dashboard/Dashboard.jsx +++ b/frontend/src/pages/dashboard/Dashboard.jsx @@ -7,6 +7,7 @@ import { useSmartDateRange } from '../../hooks/useSmartDateRange'; import useAxiosPrivate from '../../auth/hooks/useAxiosPrivate'; import useAuth from '../../auth/hooks/useAuth'; import { useCells } from '../../services/cell'; +import { getCellSensors } from '../../services/sensor'; import ArchiveModal from './components/ArchiveModal'; import BackBtn from './components/BackBtn'; import CellSelect from './components/CellSelect'; @@ -15,6 +16,7 @@ import DownloadBtn from './components/DownloadBtn'; import PowerCharts from './components/PowerCharts'; import StreamToggle from './components/StreamToggle'; import TerosCharts from './components/TerosCharts'; +import { CHART_CONFIGS } from './components/chartConfigs'; import UnifiedChart from './components/UnifiedChart'; import { io } from 'socket.io-client'; import TopNav from '../../components/TopNav'; @@ -34,6 +36,7 @@ function Dashboard() { const [powerHasData, setPowerHasData] = useState(false); const [terosHasData, setTerosHasData] = useState(false); const [liveData, setLiveData] = useState([]); + const [availableSensors, setAvailableSensors] = useState(null); // Background streaming data - always collecting in background const backgroundStreamDataRef = useRef([]); @@ -430,6 +433,26 @@ function Dashboard() { } }, [loggedIn, stream]); + // Fetch which sensor names exist for the selected cells + useEffect(() => { + if (selectedCells.length === 0) { + setAvailableSensors(null); + return; + } + const cellIds = selectedCells.map((c) => c.id); + getCellSensors(cellIds) + .then((data) => setAvailableSensors(data)) + .catch(() => setAvailableSensors(null)); + }, [selectedCells]); + + // Helper: returns true if the sensor_name for the given chart type exists + // for at least one selected cell (or while the availability check is still loading) + const sensorVisible = (type) => { + if (!availableSensors) return true; + const sensorName = CHART_CONFIGS[type]?.sensor_name; + return selectedCells.some((c) => (availableSensors[String(c.id)] ?? []).includes(sensorName)); + }; + // Check if top section should be hidden const topSectionHasData = powerHasData || terosHasData; @@ -645,141 +668,171 @@ function Dashboard() { justifyContent='spaced-evently' sx={{ width: '95%', boxSizing: 'border-box' }} > - - - - - - - - - - - - - - - + {sensorVisible('power_voltage') && ( + + )} + {sensorVisible('power_current') && ( + + )} + {sensorVisible('teros12_vwc') && ( + + )} + {sensorVisible('teros12_vwc_adj') && ( + + )} + {sensorVisible('teros12_temp') && ( + + )} + {sensorVisible('teros12_ec') && ( + + )} + {sensorVisible('soilPot') && ( + + )} + {sensorVisible('presHum') && ( + + )} + {sensorVisible('sensor') && ( + + )} + {sensorVisible('co2') && ( + + )} + {sensorVisible('temperature') && ( + + )} + {sensorVisible('soilHum') && ( + + )} + {sensorVisible('waterPress') && ( + + )} + {sensorVisible('waterFlow') && ( + + )} + {sensorVisible('waterFlowD10') && ( + + )} )} diff --git a/frontend/src/pages/dashboard/components/UnifiedChart.jsx b/frontend/src/pages/dashboard/components/UnifiedChart.jsx index f4fe009b..c15f85e4 100644 --- a/frontend/src/pages/dashboard/components/UnifiedChart.jsx +++ b/frontend/src/pages/dashboard/components/UnifiedChart.jsx @@ -2,143 +2,15 @@ import { Grid } from '@mui/material'; import { DateTime } from 'luxon'; import PropTypes from 'prop-types'; import { React, useEffect, useState, useRef } from 'react'; -import { getSensorData } from '../../../services/sensor'; +import { getSensorDataBatch } from '../../../services/sensor'; import UniversalChart from '../../../charts/UniversalChart'; +import { CHART_CONFIGS } from './chartConfigs'; import { extractUnifiedStreamValue, matchesSensorStreamType, normalizeUnifiedStreamValue, } from './unifiedChartUtils'; -const CHART_CONFIGS = { - power_voltage: { - sensor_name: 'POWER_VOLTAGE', - measurements: ['Voltage'], - units: ['mV'], - axisIds: ['y'], - chartId: 'powerVoltage', - }, - power_current: { - sensor_name: 'POWER_CURRENT', - measurements: ['Current'], - units: ['uA'], - axisIds: ['y'], - chartId: 'powerCurrent', - }, - teros12_vwc: { - sensor_name: 'TEROS12_VWC', - measurements: ['Volumetric Water Content (Raw)'], - units: ['raw'], - axisIds: ['y'], - chartId: 'teros12VWC', - }, - teros12_vwc_adj: { - sensor_name: 'TEROS12_VWC_ADJ', - measurements: ['Volumetric Water Content'], - units: ['%'], - axisIds: ['y'], - axisPolicy: 'vwcPercent', - chartId: 'teros12VWCADJ', - }, - teros12_temp: { - sensor_name: 'TEROS12_TEMP', - measurements: ['Temperature'], - units: ['°C'], - axisIds: ['y'], - chartId: 'teros12Temp', - }, - teros12_ec: { - sensor_name: 'TEROS12_EC', - measurements: ['Electrical Conductivity'], - units: ['µS/cm'], - axisIds: ['y'], - chartId: 'teros12EC', - }, - temperature: { - sensor_name: 'bme280', - measurements: ['temperature'], - units: ['°C'], - axisIds: ['y'], - chartId: 'bme280', - }, - bme280Temperature: { - sensor_name: 'bme280', - measurements: ['Temperature'], - units: ['°C'], - axisIds: ['y'], - chartId: 'bme280temp', - }, - co2: { - sensor_name: 'co2', - measurements: ['co2'], - units: ['ppm'], - axisIds: ['y'], - chartId: 'co2', - }, - presHum: { - sensor_name: 'bme280', - measurements: ['pressure', 'humidity'], - units: ['kPa', '%'], - axisIds: ['pressureAxis', 'humidityAxis'], - chartId: 'presHum', - }, - bme280Pressure: { - sensor_name: 'bme280', - measurements: ['Pressure'], - units: ['kPa'], - axisIds: ['pressureAxis'], - chartId: 'bme280pressure', - }, - bme280Humidity: { - sensor_name: 'bme280', - measurements: ['Humidity'], - units: ['%'], - axisIds: ['humidityAxis'], - chartId: 'bme280humidity', - }, - sensor: { - sensor_name: 'phytos31', - measurements: ['dielectric_permittivity'], - units: ['1 (unitless)'], - axisIds: ['y'], - chartId: 'sensor', - }, - soilPot: { - sensor_name: 'teros21', - measurements: ['soil_water_potential'], - units: ['kPa'], - axisIds: ['y'], - chartId: 'soilPot', - }, - soilHum: { - sensor_name: 'sen0308', - measurements: ['humidity'], - units: ['%'], - axisIds: ['y'], - chartId: 'soilHum', - }, - waterPress: { - sensor_name: 'sen0257', - measurements: ['pressure'], - units: ['kPa'], - axisIds: ['y'], - chartId: 'waterPress', - }, - waterFlow: { - sensor_name: 'yfs210c', - measurements: ['flow'], - units: ['L/Min'], - axisIds: ['y'], - chartId: 'waterFlow', - }, - waterFlowD10: { - sensor_name: 'D10', - measurements: ['flow'], - units: ['G/Min'], - axisIds: ['y'], - chartId: 'waterFlow', - }, -}; function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, processedData, onDataStatusChange }) { const [resample, setResample] = useState('hour'); const chartSettings = { @@ -167,16 +39,27 @@ function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, proce async function getCellChartData() { const data = {}; - // Always fetch data for all selected cells when cells change - let loadCells = cells; - for (const { id, name } of loadCells) { - data[id] = { - name: name, - }; - for (const meas of measurements) { - data[id] = { - ...data[id], - [meas]: await getSensorData(sensor_name, id, meas, startDate.toHTTP(), endDate.toHTTP(), resample), + const cellIds = cells.map((c) => c.id); + cells.forEach(({ id, name }) => { + data[id] = { name }; + }); + + for (const meas of measurements) { + const batchResult = await getSensorDataBatch( + sensor_name, + cellIds, + meas, + startDate.toHTTP(), + endDate.toHTTP(), + resample, + ); + for (const { id } of cells) { + data[id][meas] = batchResult[String(id)] ?? { + timestamp: [], + data: [], + measurement: '', + unit: '', + type: '', }; } } diff --git a/frontend/src/pages/dashboard/components/chartConfigs.js b/frontend/src/pages/dashboard/components/chartConfigs.js new file mode 100644 index 00000000..b344f043 --- /dev/null +++ b/frontend/src/pages/dashboard/components/chartConfigs.js @@ -0,0 +1,129 @@ +export const CHART_CONFIGS = { + power_voltage: { + sensor_name: 'POWER_VOLTAGE', + measurements: ['Voltage'], + units: ['mV'], + axisIds: ['y'], + chartId: 'powerVoltage', + }, + power_current: { + sensor_name: 'POWER_CURRENT', + measurements: ['Current'], + units: ['uA'], + axisIds: ['y'], + chartId: 'powerCurrent', + }, + teros12_vwc: { + sensor_name: 'TEROS12_VWC', + measurements: ['Volumetric Water Content (Raw)'], + units: ['raw'], + axisIds: ['y'], + chartId: 'teros12VWC', + }, + teros12_vwc_adj: { + sensor_name: 'TEROS12_VWC_ADJ', + measurements: ['Volumetric Water Content'], + units: ['%'], + axisIds: ['y'], + axisPolicy: 'vwcPercent', + chartId: 'teros12VWCADJ', + }, + teros12_temp: { + sensor_name: 'TEROS12_TEMP', + measurements: ['Temperature'], + units: ['°C'], + axisIds: ['y'], + chartId: 'teros12Temp', + }, + teros12_ec: { + sensor_name: 'TEROS12_EC', + measurements: ['Electrical Conductivity'], + units: ['µS/cm'], + axisIds: ['y'], + chartId: 'teros12EC', + }, + temperature: { + sensor_name: 'bme280', + measurements: ['temperature'], + units: ['°C'], + axisIds: ['y'], + chartId: 'bme280', + }, + bme280Temperature: { + sensor_name: 'BME280_TEMP', + measurements: ['Temperature'], + units: ['°C'], + axisIds: ['y'], + chartId: 'bme280temp', + }, + co2: { + sensor_name: 'co2', + measurements: ['co2'], + units: ['ppm'], + axisIds: ['y'], + chartId: 'co2', + }, + presHum: { + sensor_name: 'bme280', + measurements: ['pressure', 'humidity'], + units: ['kPa', '%'], + axisIds: ['pressureAxis', 'humidityAxis'], + chartId: 'presHum', + }, + bme280Pressure: { + sensor_name: 'BME280_PRESSURE', + measurements: ['Pressure'], + units: ['kPa'], + axisIds: ['pressureAxis'], + chartId: 'bme280pressure', + }, + bme280Humidity: { + sensor_name: 'BME280_HUMIDITY', + measurements: ['Humidity'], + units: ['%'], + axisIds: ['humidityAxis'], + chartId: 'bme280humidity', + }, + sensor: { + sensor_name: 'phytos31', + measurements: ['dielectric_permittivity'], + units: ['1 (unitless)'], + axisIds: ['y'], + chartId: 'sensor', + }, + soilPot: { + sensor_name: 'teros21', + measurements: ['soil_water_potential'], + units: ['kPa'], + axisIds: ['y'], + chartId: 'soilPot', + }, + soilHum: { + sensor_name: 'sen0308', + measurements: ['humidity'], + units: ['%'], + axisIds: ['y'], + chartId: 'soilHum', + }, + waterPress: { + sensor_name: 'sen0257', + measurements: ['pressure'], + units: ['kPa'], + axisIds: ['y'], + chartId: 'waterPress', + }, + waterFlow: { + sensor_name: 'yfs210c', + measurements: ['flow'], + units: ['L/Min'], + axisIds: ['y'], + chartId: 'waterFlow', + }, + waterFlowD10: { + sensor_name: 'D10', + measurements: ['flow'], + units: ['G/Min'], + axisIds: ['y'], + chartId: 'waterFlow', + }, +}; diff --git a/frontend/src/services/sensor.js b/frontend/src/services/sensor.js index 109792d6..99693beb 100644 --- a/frontend/src/services/sensor.js +++ b/frontend/src/services/sensor.js @@ -8,6 +8,20 @@ export const getSensorData = (name, cellId, meas, startTime, endTime, resample = .then((res) => res.data); }; +export const getSensorDataBatch = (name, cellIds, meas, startTime, endTime, resample = 'hour') => { + return axios + .get( + `${process.env.PUBLIC_URL}/api/sensor/?name=${name}&cellIds=${cellIds.join(',')}&measurement=${meas}&startTime=${startTime}&endTime=${endTime}&resample=${resample}`, + ) + .then((res) => res.data); +}; + +export const getCellSensors = (cellIds) => { + return axios + .get(`${process.env.PUBLIC_URL}/api/cell-sensors/?cell_ids=${cellIds.join(',')}`) + .then((res) => res.data); +}; + export const streamSensorData = ( name, cellId,