Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/rtgs_lab_tools/device_monitoring/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion src/rtgs_lab_tools/device_monitoring/data_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .config import (
BATTERY_VOLTAGE_MIN,
CRITICAL_ERRORS,
INBOX_HUMIDITY_MAX,
MISSING_NODE_THRESHOLD_HOURS,
SYSTEM_POWER_MAX,
)
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -93,6 +110,7 @@ def analyze_data(data):
flagged = False
battery_val = None
system_val = None
humidity_val = None
errors_dict = {}

# Get battery voltage
Expand All @@ -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]
Expand All @@ -126,18 +150,24 @@ 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"]

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"):
Expand All @@ -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,
}
Expand Down
23 changes: 23 additions & 0 deletions src/rtgs_lab_tools/device_monitoring/data_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]]
)
39 changes: 31 additions & 8 deletions src/rtgs_lab_tools/device_monitoring/message_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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 = (
Expand All @@ -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
Expand All @@ -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 = []
Expand Down Expand Up @@ -199,17 +212,22 @@ def generate_device_card_html(node_id, result, device_name, console_url):

<table style="width: 100%; margin-bottom: 8px;">
<tr>
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 33%;">
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 25%;">
<div style="font-size: 20px; font-weight: bold; margin-bottom: 3px; color: {battery_color};">{battery_str}</div>
<div style="font-size: 11px; color: #6c757d; text-transform: uppercase;">Battery</div>
</td>
<td style="width: 2%;"></td>
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 33%;">
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 25%;">
<div style="font-size: 20px; font-weight: bold; margin-bottom: 3px; color: {system_color};">{system_str}</div>
<div style="font-size: 11px; color: #6c757d; text-transform: uppercase;">System Power</div>
</td>
<td style="width: 2%;"></td>
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 33%;">
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 25%;">
<div style="font-size: 20px; font-weight: bold; margin-bottom: 3px; color: {humidity_color};">{humidity_str}</div>
<div style="font-size: 11px; color: #6c757d; text-transform: uppercase;">Inbox RH</div>
</td>
<td style="width: 2%;"></td>
<td style="text-align: center; padding: 8px; background-color: #f8f9fa; border-radius: 4px; width: 25%;">
<div style="font-size: 20px; font-weight: bold; margin-bottom: 3px; color: {error_color};">{len(errors)}</div>
<div style="font-size: 11px; color: #6c757d; text-transform: uppercase;">Errors</div>
</td>
Expand Down Expand Up @@ -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"):
Expand All @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down