diff --git a/src/rtgs_lab_tools/device_monitoring/config.py b/src/rtgs_lab_tools/device_monitoring/config.py index 1624a9c4..a518a0cd 100644 --- a/src/rtgs_lab_tools/device_monitoring/config.py +++ b/src/rtgs_lab_tools/device_monitoring/config.py @@ -6,8 +6,17 @@ # System power threshold 0.364W (double the average of 0.182) SYSTEM_POWER_MAX = 0.364 +# inbox relative humidity threshold +INBOX_HUMIDITY_MAX = 65 + # Critical errors that trigger alerts -CRITICAL_ERRORS = ["SD_ACCESS_FAIL", "FRAM_ACCESS_FAIL"] +CRITICAL_ERRORS = [ + "SD_ACCESS_FAIL", + "FRAM_ACCESS_FAIL", + "FIND_FAIL", + "FRAM_SPACE_CRITICAL", + "FRAM_SPACE_WARNING", +] # Historic monitoring thresholds MISSING_NODE_THRESHOLD_HOURS = 24 # Hours since last contact to mark node as missing diff --git a/src/rtgs_lab_tools/device_monitoring/data_analyzer.py b/src/rtgs_lab_tools/device_monitoring/data_analyzer.py index 564c93cb..1fd453c8 100644 --- a/src/rtgs_lab_tools/device_monitoring/data_analyzer.py +++ b/src/rtgs_lab_tools/device_monitoring/data_analyzer.py @@ -21,6 +21,7 @@ from .config import ( BATTERY_VOLTAGE_MIN, CRITICAL_ERRORS, + INBOX_HUMIDITY_MAX, MISSING_NODE_THRESHOLD_HOURS, SYSTEM_POWER_MAX, ) @@ -43,6 +44,7 @@ def analyze_data(data): battery_df = data.get("battery_data") error_df = data.get("error_data") system_df = data.get("system_current_data") + humidity_df = data.get("inbox_humidity_data") # Get all unique node_ids from all DataFrames all_node_ids = set() @@ -52,6 +54,8 @@ def analyze_data(data): all_node_ids.update(error_df.index) if system_df is not None and hasattr(system_df, "index"): all_node_ids.update(system_df.index) + if humidity_df is not None and hasattr(humidity_df, "index"): + all_node_ids.update(humidity_df.index) # Identify nodes that haven't been heard from in the last X hours cutoff_time = datetime.now() - timedelta(hours=MISSING_NODE_THRESHOLD_HOURS) @@ -85,6 +89,19 @@ def analyze_data(data): ): most_recent_timestamp = system_timestamp + if humidity_df is not None and node_id in humidity_df.index: + inbox_timestamp = humidity_df.loc[node_id, "timestamp"] + if inbox_timestamp and pd.notna(inbox_timestamp): + if hasattr(inbox_timestamp, "to_pydatetime"): + inbox_timestamp = inbox_timestamp.to_pydatetime() + elif isinstance(inbox_timestamp, str): + inbox_timestamp = pd.to_datetime(inbox_timestamp).to_pydatetime() + if ( + most_recent_timestamp is None + or inbox_timestamp > most_recent_timestamp + ): + most_recent_timestamp = inbox_timestamp + # If node has data within last 24 hours, it's considered "recent" if most_recent_timestamp and most_recent_timestamp > cutoff_time: recent_node_ids.add(node_id) @@ -93,6 +110,7 @@ def analyze_data(data): flagged = False battery_val = None system_val = None + humidity_val = None errors_dict = {} # Get battery voltage @@ -107,6 +125,12 @@ def analyze_data(data): if system_val > SYSTEM_POWER_MAX: flagged = True + # Get inbox humidity + if humidity_df is not None and node_id in humidity_df.index: + humidity_val = float(humidity_df.loc[node_id, "inbox_humidity"]) + if humidity_val > INBOX_HUMIDITY_MAX: + flagged = True + # Get errors if error_df is not None and node_id in error_df.index: error_row = error_df.loc[node_id] @@ -126,6 +150,7 @@ def analyze_data(data): # Get timestamps battery_timestamp = None system_timestamp = None + humidity_timestamp = None if battery_df is not None and node_id in battery_df.index: battery_timestamp = battery_df.loc[node_id, "timestamp"] @@ -133,11 +158,16 @@ def analyze_data(data): if system_df is not None and node_id in system_df.index: system_timestamp = system_df.loc[node_id, "timestamp"] + if humidity_df is not None and node_id in humidity_df.index: + humidity_timestamp = humidity_df.loc[node_id, "timestamp"] + # Determine if this node is missing (not heard from in 24+ hours) is_missing_node = node_id not in recent_node_ids # Calculate time since last heard from - most_recent_timestamp = system_timestamp or battery_timestamp + most_recent_timestamp = ( + system_timestamp or battery_timestamp or humidity_timestamp + ) last_heard = None if most_recent_timestamp: if hasattr(most_recent_timestamp, "to_pydatetime"): @@ -151,9 +181,11 @@ def analyze_data(data): "flagged": flagged or is_missing_node, # Flag missing nodes "battery": battery_val, "system": system_val, + "humidity": humidity_val, "errors": errors_dict, "battery_timestamp": battery_timestamp, "system_timestamp": system_timestamp, + "humidity_timestamp": humidity_timestamp, "is_missing": is_missing_node, "last_heard": last_heard, } diff --git a/src/rtgs_lab_tools/device_monitoring/data_formatter.py b/src/rtgs_lab_tools/device_monitoring/data_formatter.py index 10b09d2e..ebf72427 100644 --- a/src/rtgs_lab_tools/device_monitoring/data_formatter.py +++ b/src/rtgs_lab_tools/device_monitoring/data_formatter.py @@ -55,11 +55,15 @@ def format_data_with_parser(data_frame): # system usage system_usage_df = create_system_usage_dataframe(parsed_df) + # inbox humidity + inbox_humidity_df = create_inbox_humidity_dataframe(parsed_df) + final_dict = { "parsed_data": parsed_df, "battery_data": battery_voltages_df, "error_data": error_counts_df, "system_current_data": system_usage_df, + "inbox_humidity_data": inbox_humidity_df, } return final_dict @@ -133,3 +137,22 @@ def create_error_count_dataframe(df): ) return error_counts + + +# Create a DataFrame with inbox humidity data by node id +def create_inbox_humidity_dataframe(df): + """Extract inbox humidity from Kestrel devices by node_id.""" + kestrel_inbox_humidity = df[ + (df["device_type"] == "Kestrel") & (df["measurement_name"] == "RH") + ].copy() + kestrel_inbox_humidity["timestamp"] = pd.to_datetime( + kestrel_inbox_humidity["timestamp"] + ) + kestrel_inbox_humidity["inbox_humidity"] = pd.to_numeric( + kestrel_inbox_humidity["value"], errors="coerce" + ) + return ( + kestrel_inbox_humidity.sort_values("timestamp") + .groupby("node_id") + .last()[["inbox_humidity", "timestamp"]] + ) diff --git a/src/rtgs_lab_tools/device_monitoring/message_builder.py b/src/rtgs_lab_tools/device_monitoring/message_builder.py index 5e1c1bcc..5a9cb648 100644 --- a/src/rtgs_lab_tools/device_monitoring/message_builder.py +++ b/src/rtgs_lab_tools/device_monitoring/message_builder.py @@ -26,6 +26,7 @@ BATTERY_VOLTAGE_MIN, CRITICAL_ERRORS, HTTP_SUCCESS_CODE, + INBOX_HUMIDITY_MAX, MISSING_NODE_THRESHOLD_HOURS, MISSING_NODES_HEADER, MISSING_NODES_SEPARATOR_LENGTH, @@ -102,12 +103,14 @@ def generate_device_card_html(node_id, result, device_name, console_url): flagged = result.get("flagged", False) battery = result.get("battery") system = result.get("system") + humidity = result.get("humidity") errors = result.get("errors", {}) battery_timestamp = result.get("battery_timestamp") system_timestamp = result.get("system_timestamp") + humidity_timestamp = result.get("humidity_timestamp") # Format timestamp - timestamp = system_timestamp or battery_timestamp + timestamp = system_timestamp or battery_timestamp or humidity_timestamp timestamp_str = UNKNOWN_VALUE_TEXT if timestamp is not None: if hasattr(timestamp, "strftime"): @@ -138,6 +141,7 @@ def generate_device_card_html(node_id, result, device_name, console_url): if system is not None else UNKNOWN_VALUE_TEXT ) + humidity_str = f"{humidity:.1f}%" if humidity is not None else UNKNOWN_VALUE_TEXT # Color code metrics battery_color = ( @@ -148,6 +152,11 @@ def generate_device_card_html(node_id, result, device_name, console_url): system_color = ( "#fd7e14" if (system is not None and system > SYSTEM_POWER_MAX) else "#17a2b8" ) + humidity_color = ( + "#fd7e14" + if (humidity is not None and humidity > INBOX_HUMIDITY_MAX) + else "#17a2b8" + ) error_color = "#dc3545" if len(errors) > 0 else "#6c757d" # Console link @@ -170,6 +179,10 @@ def generate_device_card_html(node_id, result, device_name, console_url): issues.append( f"System power HIGH ({system:.{SYSTEM_POWER_DECIMAL_PRECISION}f}{POWER_UNIT} > {SYSTEM_POWER_MAX}{POWER_UNIT})" ) + if humidity is not None and humidity > INBOX_HUMIDITY_MAX: + issues.append( + f"Inbox humidity HIGH ({humidity:.1f}% > {INBOX_HUMIDITY_MAX}%)" + ) # Check for critical errors critical_errors = [] @@ -199,17 +212,22 @@ def generate_device_card_html(node_id, result, device_name, console_url):
| + |
{battery_str}
Battery
|
- | + |
{system_str}
System Power
|
- | + |
+ {humidity_str}
+ Inbox RH
+ |
+ + |
{len(errors)}
Errors
|
@@ -354,12 +372,14 @@ def _process_node(node_id, result, lines, add_spacing=False):
# Get all metrics
battery = result.get("battery")
system = result.get("system")
+ humidity = result.get("humidity")
errors = result.get("errors", {})
battery_timestamp = result.get("battery_timestamp")
system_timestamp = result.get("system_timestamp")
+ humidity_timestamp = result.get("humidity_timestamp")
# Format timestamp - use the most recent one available
- timestamp = system_timestamp or battery_timestamp
+ timestamp = system_timestamp or battery_timestamp or humidity_timestamp
timestamp_str = ""
if timestamp is not None:
if hasattr(timestamp, "strftime"):
@@ -380,10 +400,9 @@ def _process_node(node_id, result, lines, add_spacing=False):
if system is not None
else UNKNOWN_VALUE_TEXT
)
+ humidity_str = f"{humidity:.1f}%" if humidity is not None else UNKNOWN_VALUE_TEXT
- metrics_line = (
- f" Battery: {battery_str} | System: {system_str} | Errors: {len(errors)} types"
- )
+ metrics_line = f" Battery: {battery_str} | System: {system_str} | Inbox RH: {humidity_str} | Errors: {len(errors)} types"
lines.append(metrics_line)
# Show errors if any
@@ -443,6 +462,10 @@ def _process_node(node_id, result, lines, add_spacing=False):
issues.append(
f"System power HIGH ({system:.{SYSTEM_POWER_DECIMAL_PRECISION}f}{POWER_UNIT} > {SYSTEM_POWER_MAX}{POWER_UNIT})"
)
+ if humidity is not None and humidity > INBOX_HUMIDITY_MAX:
+ issues.append(
+ f"Inbox humidity HIGH ({humidity:.1f}% > {INBOX_HUMIDITY_MAX}%)"
+ )
# Check for critical errors
critical_errors = []