From 28472d8891020e0922fa3d650deed8bc1b43b32d Mon Sep 17 00:00:00 2001 From: Alexandra Ermakova Date: Sat, 20 Dec 2025 16:54:11 -0800 Subject: [PATCH] Initial AI python script --- new_smart_agent.py | 993 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 993 insertions(+) create mode 100644 new_smart_agent.py diff --git a/new_smart_agent.py b/new_smart_agent.py new file mode 100644 index 0000000..d2a523b --- /dev/null +++ b/new_smart_agent.py @@ -0,0 +1,993 @@ +import pandas as pd +import sys +import os +import time +from openai import OpenAI +import urllib.request +import requests +import datetime +import zipfile +import io +import math +import re + +# --------------------------------------------------------- +# GLOBAL CONFIGURATION & CONSTANTS +# --------------------------------------------------------- + +DEBUG = 0 # Debug printout levels. Set to 1 to see verbose output. + +WELLNUO_USER = "dive" + +# Mappings to translate hardware IDs to human-readable room names +DEVICE_TO_ROOM_MAPPING = { + 720: "Bedroom", + 719: "Bathroom Main", + 716: "Kitchen", + 663: "Office", + 714: "Living Room", + 718: "Bathroom Guest", + 715: "Dining Room" +} + +WELL_TO_ROOM_MAPPING = { + "470": "Living Room", + "473": "Bathroom Guest", + "475": "Bedroom", + "471": "Dining Room", + "474": "Bathroom Main", + "472": "Kitchen", + "431": "Office" +} + +# Settings for converting radar sensor readings into a narrative timeline +LATCH_TIME_MINUTES = 15 # Time to hold a room state active during silence before switching +NOISE_THRESHOLD = 15 # Minimum signal strength to consider a room "active" +MIN_DURATION_BATHROOM_THRESHOLD = 2 # Minimum duration (mins) to keep a block; shorter blocks are merged (glitch removal) +MIN_DURATION_OTHER_THRESHOLD = 10 # Minimum duration (mins) to keep a block; shorter blocks are merged (glitch removal) +# Constants for Light Logic +LIGHT_THRESHOLD = 200 # Lux value above which a room is considered "Lit" +EVENING_START_MIN = 18 * 60 # 6:00 PM (Minute 1080) +MORNING_END_MIN = 6 * 60 # 6:00 AM (Minute 360) + +# Define available LLM models. +MODEL_OPTIONS = { + "1": { + "name": "Local: Ollama (qwen3:8b)", + "type": "ollama", + "base_url": "http://localhost:11434/v1", + "api_key": "ollama", + "model_name": "qwen3:8b" + }, + "2": { + "name": "Cloud: Groq (GPT-OSS-20B)", + "type": "cloud", + "base_url": "https://api.groq.com/openai/v1", + "api_key": "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", + "model_name": "openai/gpt-oss-20b" + }, + "3": { + "name": "Cloud: Groq (GPT-OSS-120B)", + "type": "cloud", + "base_url": "https://api.groq.com/openai/v1", + "api_key": "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", + "model_name": "openai/gpt-oss-20b" + } + # "4": { + # "name": "Cloud: Alibaba (Qwen-Flash)", + # "type": "cloud", + # "base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + # "api_key": "sk-...", + # "model_name": "qwen-flash" + # } +} + +# Global client variable (will be initialized after user selection) +client = None + + +# Helper function for time formatting +def minutes_to_time(minutes): + """ + Converts minutes from start of day (0-1440) to HH:MM AM/PM string. + """ + hours = minutes // 60 + mins = minutes % 60 + period = "AM" if hours < 12 else "PM" + if hours == 0: hours = 12 + if hours > 12: hours -= 12 + return f"{hours:02d}:{mins:02d} {period}" + +# Helper function for 2 significant figures +def format_sig_figs(val): + if val is None or pd.isna(val) or val == 0: + return "0" + # Calculate the power of 10 to round to the second significant digit + try: + return f"{round(val, -int(math.floor(math.log10(abs(val)))) + 1):.0f}" + except: + return "0" + +def get_timeline_humidity_profile(timelines): + """ + Parses the generated text timelines to extract a sorted, deduplicated list + of average humidity values (H) recorded during Bathroom visits. + """ + all_h_values = [] + + # Regex to find H value in a line containing "Bathroom" + # Logic: Look for 'Bathroom', then look for 'H:' followed by digits + pattern = re.compile(r"Bathroom.*H:\s*(\d+)") + + for date, text in timelines.items(): + for line in text.split('\n'): + # Only process lines describing bathroom visits + if "Bathroom" in line: + match = pattern.search(line) + if match: + try: + val = int(match.group(1)) + all_h_values.append(val) + except ValueError: + pass + + if not all_h_values: + return "" + + # Deduplicate and sort + unique_sorted = sorted(list(set(all_h_values))) + + return f"### REFERENCE HUMIDITY DATA\nSorted list of average Bathroom Humidity levels (H) observed during visits: {unique_sorted}" + +# --------------------------------------------------------- +# AUTHENTICATION +# --------------------------------------------------------- +def get_wellnuo_credentials(): + """ + Authenticates with the Wellnuo API and retrieves the session token. + + Returns: + str: Access token if successful, None otherwise. + """ + url = "https://eluxnetworks.net/function/well-api/api" + + # Payload matching API requirements + payload = { + "function": "credentials", + "user_name": WELLNUO_USER, + "ps": "D@v1d3", + "clientId": "001", + "nonce": "111" + } + + print("Authenticating with Wellnuo API...") + try: + # requests.post with 'data=' sends application/x-www-form-urlencoded + response = requests.post(url, data=payload) + response.raise_for_status() + + data = response.json() + + # Attempt to locate token in standard response fields + token = data.get("access_token") or data.get("data", {}).get("token") + + if token: + print("✅ Wellnuo authentication successful.") + return token + else: + print(f"❌ Wellnuo authentication failed: Token not found in response: {data}") + return None + + except Exception as e: + print(f"❌ Error connecting to Wellnuo API: {e}") + return None + +def preprocess_sensor_data(api_response, data_name="sensor"): + """ + Parses raw JSON sensor data (light, humidity, etc.) covering multiple days + and splits it into a dictionary of daily DataFrames. + + Args: + api_response (dict): The JSON response from the API. + data_name (str): Label for debug prints (e.g., "light", "humidity"). + + Returns: + dict: Keys are date strings (e.g., "2025-12-15"). + Values are DataFrames (Index=0..1439, Columns=Room Names, Values=Sensor Reading). + """ + if not api_response or 'chart_data' not in api_response: + print(f"Warning: Invalid or missing {data_name} data.") + return {} + + # 1. Flatten the nested JSON into a list of records + all_records = [] + for room_entry in api_response['chart_data']: + room_name = room_entry['name'] + # room_entry['data'] is a list of [timestamp_str, value] + for ts, val in room_entry['data']: + all_records.append({ + 'timestamp': ts, + 'value': val, + 'room': room_name + }) + + if not all_records: + return {} + + # 2. Create a Master DataFrame + df = pd.DataFrame(all_records) + + # Use format='ISO8601' to handle timestamps that mix precision + try: + df['timestamp'] = pd.to_datetime(df['timestamp'], format='ISO8601') + except ValueError: + # Fallback if specific ISO format fails + df['timestamp'] = pd.to_datetime(df['timestamp'], format='mixed') + + # Extract Date string (for grouping) and Minute of Day (for indexing) + df['date_str'] = df['timestamp'].dt.strftime('%Y-%m-%d') + df['minute'] = df['timestamp'].dt.hour * 60 + df['timestamp'].dt.minute + + daily_dfs = {} + + # 3. Group by Date and create individual DataFrames + for date_key, group in df.groupby('date_str'): + # Pivot: Index=Minute, Columns=Room, Values=Sensor Value + # We use pivot_table with aggfunc='max' to handle duplicate minutes if any + daily_df = group.pivot_table(index='minute', columns='room', values='value', aggfunc='max') + + # Reindex to ensure full 0-1439 grid + daily_df = daily_df.reindex(range(1440), fill_value=0) + + # Forward fill small gaps (optional) then fill NaNs + daily_df = daily_df.ffill(limit=5).fillna(0) + + daily_dfs[date_key] = daily_df + + return daily_dfs + +def print_sensor_statistics(daily_sensor_dfs, sensor_type="Sensor", unit=""): + """ + Aggregates sensor data from all available days and prints percentiles for each room. + + Percentiles shown: + - 0% to 90% in increments of 10% + - 90% to 100% in increments of 1% + + Args: + daily_sensor_dfs (dict): Dictionary of daily DataFrames. + sensor_type (str): Name for the header (e.g., "HUMIDITY", "LIGHT"). + unit (str): Unit suffix for the values (e.g., "%", " Lux"). + """ + if not daily_sensor_dfs: + print(f"No {sensor_type} data available to analyze.") + return + + print("\n========================================================") + print(f" {sensor_type.upper()} STATISTICS (Aggregated over all days) ") + print("========================================================") + + # 1. Combine all daily DataFrames into one large DataFrame + combined_df = pd.concat(daily_sensor_dfs.values(), axis=0, ignore_index=True) + + # 2. Iterate through each room (column) + for room in combined_df.columns: + # Extract the data series for this room + series = combined_df[room] + + # CRITICAL: Filter out 0.0 values (missing data or "off" states) + valid_series = series[series > 0] + + if valid_series.empty: + print(f"\nRoom: {room} - No valid data (>0) found.") + continue + + print(f"\nRoom: {room}") + print("-" * 20) + + # 3. Calculate Percentiles + # Generate list: 0.0, 0.1, ... 0.9 + percentiles = [i / 10.0 for i in range(10)] + # Add fine-grained list: 0.91, 0.92, ... 1.0 + percentiles.extend([i / 100.0 for i in range(91, 101)]) + + stats = valid_series.quantile(percentiles) + + # 4. Print formatted results + for p, val in stats.items(): + # Use round() to handle floating point precision issues (e.g. 0.1 * 100 = 9.999...) + label = f"{int(round(p * 100))}%" + print(f"{label:<5}: {val:.1f}{unit}") + +# --------------------------------------------------------- +# CORE LOGIC: TIMELINE GENERATION +# --------------------------------------------------------- +def create_timeline(filename, light_df=None, humidity_df=None): + """ + Transforms raw radar sensor CSV data into a coherent, narrative timeline of user activity + for a single day. + + This function implements a multi-stage algorithm to determine the most likely room + occupied by a person based on radar signal strength, while cross-referencing light + sensor data to filter out false positives during dark hours. + + It also calculates average environmental conditions (Light and Humidity) for each + identified activity block. + + Algorithm Overview: + ------------------- + 1. Data Preparation: + - Maps hardware Device IDs to human-readable Room Names. + - Pivots raw data into a minute-by-minute grid (0 to 1439 minutes). + + 2. State Machine & Signal Evaluation (The Core Loop): + - Iterates through every minute of the day. + - Light Sensor Logic (Dark Hours Rule): Between 6:00 PM and 6:00 AM, the algorithm + restricts the search space. Only rooms with active light (> 200 Lux) are considered + valid candidates. + * EXCEPTION: The "Bedroom" is always a valid candidate, even in the dark, to account for sleeping. + - Signal Selection: Determines which valid candidate room has the strongest radar signal. + - Rejection Tracking: Logs instances where a room had the strongest raw signal but was + ignored because it was dark (and therefore deemed a false positive or ghost signal). + - Heuristics: Applies logic for "Sleeping" states and "Latching" (holding the previous + state during short periods of silence to prevent flickering). + + 3. Iterative Post-Processing (Gap Filling): + - A cleaning loop runs (up to 10 times) to refine the timeline. + - Merges consecutive entries for the same room. + - Identifies "glitches" (durations shorter than 2 mins for Bathrooms or 5 mins for other rooms). + - "Absorbs" these glitches into the neighboring room with the strongest signal context. + + 4. Reporting: + - Prints a summary of "Light Rule Exclusions" (times where strong signals were ignored due to darkness). + + 5. Formatting & Environmental Analysis: + - Iterates through the final timeline blocks. + - Calculates the average Light (Lux) and Humidity (%) for the specific room and time interval. + - Returns the final formatted text timeline. + + Args: + filename (str): Path to the CSV file containing radar data. + light_df (pd.DataFrame, optional): Preprocessed DataFrame containing light sensor readings + (Index=Minute, Columns=Room Names). + humidity_df (pd.DataFrame, optional): Preprocessed DataFrame containing humidity sensor readings + (Index=Minute, Columns=Room Names). + + Returns: + str: A formatted string describing the timeline of events with environmental data. + """ + if 'DEBUG' in globals() and DEBUG > 0: + print(f"\nProcessing data from: {os.path.basename(filename)}...") + try: + df = pd.read_csv(filename) + except Exception as e: + return f"Error reading CSV: {e}" + + # --------------------------------------------------------- + # 1. Data Preparation + # --------------------------------------------------------- + # Map Device IDs to Names + mapping = globals().get('DEVICE_TO_ROOM_MAPPING', globals().get('ROOM_MAPPING', {})) + df['device_id'] = df['device_id'].map(mapping).fillna(df['device_id']) + + # Pivot Data (Minute-by-Minute Grid) + # Creates a matrix where Index is the minute of the day (0-1439) and Columns are Room Names. + df['minute'] = df.groupby('device_id').cumcount() + pivot_df = df.pivot(index='minute', columns='device_id', values='radar').fillna(0) + + # Initialize State Machine variables + raw_log = [] + rejection_log = [] # To store [minute, room_name] of signals ignored due to the Light Rule + current_state = "Out of House/Inactive" + start_time = 0 + silence_counter = 0 + last_known_room = "Unknown" + + # --------------------------------------------------------- + # 2. State Machine Loop + # --------------------------------------------------------- + for minute, row in pivot_df.iterrows(): + + # --- LIGHT SENSOR LOGIC --- + # Default: Consider all rooms available in the radar data as candidates + candidate_rooms = row.index + + # Check if we have light data and if we are in the "Dark Hours" (6pm - 6am) + is_dark_hours = (minute < MORNING_END_MIN) or (minute > EVENING_START_MIN) + + if light_df is not None and not light_df.empty and is_dark_hours: + try: + if minute in light_df.index: + light_row = light_df.loc[minute] + + # Find rooms where light is ON (> 200 Lux) + lit_rooms_index = light_row[light_row > LIGHT_THRESHOLD].index + + # Intersect with radar rooms to ensure validity (room must exist in both datasets) + lit_rooms = lit_rooms_index.intersection(row.index) + + # LOGIC: + # If at least one room is lit, we filter the search space. + # If NO rooms are lit (e.g., user is sleeping or sitting in the dark), + # we fall back to standard behavior (all rooms are candidates). + if not lit_rooms.empty: + # Start with the lit rooms + allowed = set(lit_rooms) + # EXCEPTION: "Bedroom" is always allowed, even if dark (for sleeping). + if "Bedroom" in row.index: + allowed.add("Bedroom") + + # Convert back to index for pandas selection + candidate_rooms = list(allowed) + except Exception as e: + if DEBUG > 0: print(f"Light logic error at min {minute}: {e}") + pass + + # --- REJECTION TRACKING --- + # Determine which room had the strongest signal BEFORE applying light filters. + raw_max_signal = row.max() + raw_best_room = str(row.idxmax()) + + # If the raw winner is strong enough to be real... + if raw_max_signal > NOISE_THRESHOLD: + # ...but it is NOT in our filtered candidate list (it was dark)... + if raw_best_room not in candidate_rooms: + # ...then it was rejected due to the light rule. Log it for reporting. + rejection_log.append({ + 'minute': minute, + 'room': raw_best_room + }) + + # --- Signal Evaluation (Using Candidate Rooms) --- + # We only look for the max signal among the candidate (Lit Rooms + Bedroom) + if len(candidate_rooms) > 0: + max_signal = row[candidate_rooms].max() + if pd.notna(max_signal): + strongest_room = str(row[candidate_rooms].idxmax()) + else: + max_signal = 0 + strongest_room = "Unknown" + else: + max_signal = 0 + strongest_room = "Unknown" + + # Check against Noise Threshold + if max_signal > NOISE_THRESHOLD: + active_location = strongest_room + silence_counter = 0 + last_known_room = active_location + else: + active_location = None + silence_counter += 1 + + # --- Heuristics / Logic Tree --- + effective_location = current_state + + if active_location: + effective_location = active_location + else: + # Handle periods of silence/inactivity + is_night_time = (minute < 480) or (minute > 1320) + + # Heuristic: If last known room was Bedroom and it's night, assume Sleeping. + if last_known_room == "Bedroom" and is_night_time: + effective_location = "Bedroom (Sleeping)" + silence_counter = 0 + # Heuristic: Latching. If silence is short, assume user is still in the last room. + elif silence_counter < LATCH_TIME_MINUTES and last_known_room != "Unknown": + effective_location = last_known_room + else: + effective_location = "Out of House/Inactive" + + # --- State Change Detection --- + if effective_location != current_state: + if current_state != "Unknown": + # Log the completed block + raw_log.append({ + "room": current_state, + "start": start_time, + "end": minute, + "duration": minute - start_time + }) + current_state = effective_location + start_time = minute + + # Add final block after loop finishes + duration = len(pivot_df) - start_time + raw_log.append({"room": current_state, "start": start_time, "end": len(pivot_df), "duration": duration}) + + # --------------------------------------------------------- + # 3. Iterative Cleaning (Merge & Gap Filling) + # --------------------------------------------------------- + # This loop repeatedly merges consecutive identical blocks and removes "glitches" + # (blocks that are too short to be significant). + iteration = 0 + max_iterations = 10 + + while iteration < max_iterations: + # A. Merge Step: Combine consecutive blocks of the same room + merged_log = [] + if raw_log: + current_block = raw_log[0] + for next_block in raw_log[1:]: + if next_block['room'] == current_block['room']: + # Extend the current block + current_block['end'] = next_block['end'] + current_block['duration'] = current_block['end'] - current_block['start'] + else: + merged_log.append(current_block) + current_block = next_block + merged_log.append(current_block) + raw_log = merged_log + + # B. Scan & Fix Step: Remove short duration glitches + changes_made = False + for i in range(len(raw_log)): + block = raw_log[i] + + # Dynamic Threshold: Bathrooms allowed to be short (2 mins), others need 5 mins. + if "Bathroom" in block['room']: + dynamic_threshold = 2 + else: + dynamic_threshold = 5 + + if block['duration'] < dynamic_threshold: + # Identify neighbors + prev_block = raw_log[i-1] if i > 0 else None + next_block = raw_log[i+1] if i < len(raw_log) - 1 else None + winner_room = None + + # Compare average signal strength of neighbors during the glitch period + if prev_block and next_block: + room_a = prev_block['room'] + room_b = next_block['room'] + sig_a = -1 + if room_a in pivot_df.columns: + sig_a = pivot_df.loc[block['start']:block['end']-1, room_a].mean() + sig_b = -1 + if room_b in pivot_df.columns: + sig_b = pivot_df.loc[block['start']:block['end']-1, room_b].mean() + + if sig_a >= sig_b: winner_room = room_a + else: winner_room = room_b + elif prev_block: + winner_room = prev_block['room'] + elif next_block: + winner_room = next_block['room'] + + # Apply the fix + if winner_room and winner_room != block['room']: + block['room'] = winner_room + changes_made = True + + # If no changes were made, the timeline is stable. + if not changes_made: + break + iteration += 1 + + # --------------------------------------------------------- + # 4. Print Rejection Log (Merged & Filtered) + # --------------------------------------------------------- + # This section processes the 'rejection_log' to print a human-readable summary + # of times where radar signals were ignored because the room was dark. + if rejection_log: + # Extract date from filename (e.g., "21_2025-12-15_by_minute_rc_data.csv" -> "2025-12-15") + try: + date_str = os.path.basename(filename).split('_')[1] + except: + date_str = "Unknown Date" + + # Sort by minute just in case + rejection_log.sort(key=lambda x: x['minute']) + + curr_rej = rejection_log[0] + start_m = curr_rej['minute'] + end_m = start_m + room = curr_rej['room'] + + # Iterate to merge consecutive rejected minutes into blocks + for i in range(1, len(rejection_log)): + next_rej = rejection_log[i] + + # Check if consecutive minute AND same room + if (next_rej['minute'] == end_m + 1) and (next_rej['room'] == room): + end_m = next_rej['minute'] + else: + # Block ended. Calculate duration + duration = end_m - start_m + 1 + + # Determine threshold: 2 mins for Bathroom, 5 mins for others + threshold = 2 if "Bathroom" in room else 5 + + # Print only if duration meets the threshold (ignore short noise) + if duration >= threshold: + start_s = minutes_to_time(start_m) + end_s = minutes_to_time(end_m + 1) + print(f"On {date_str}, between {start_s} and {end_s}, {room} had the highest radar presence signal but had light sensor reading below threshold for dark hours of the day.") + + # Reset for next block + curr_rej = next_rej + start_m = curr_rej['minute'] + end_m = start_m + room = curr_rej['room'] + + # Process the final block after loop finishes + duration = end_m - start_m + 1 + threshold = 2 if "Bathroom" in room else 5 + if duration >= threshold: + start_s = minutes_to_time(start_m) + end_s = minutes_to_time(end_m + 1) + print(f"On {date_str}, between {start_s} and {end_s}, {room} had the highest radar presence signal but had light sensor reading below threshold for dark hours of the day.") + + # --------------------------------------------------------- + # 5. Format Output & Environmental Analysis + # --------------------------------------------------------- + final_text = [] + for entry in raw_log: + start_s = minutes_to_time(entry['start']) + end_s = minutes_to_time(entry['end']) + + room_name = entry['room'] + start_idx = entry['start'] + end_idx = entry['end'] + + env_parts = [] + + # --- Process Light (L) --- + if light_df is not None and not light_df.empty and room_name in light_df.columns: + segment = light_df.loc[start_idx : end_idx - 1, room_name] + if not segment.empty: + avg_l = segment.mean() + env_parts.append(f"L:{format_sig_figs(avg_l)}") + + # --- Process Humidity (H) --- + if humidity_df is not None and not humidity_df.empty and room_name in humidity_df.columns: + segment = humidity_df.loc[start_idx : end_idx - 1, room_name] + if not segment.empty: + avg_h = segment.mean() + env_parts.append(f"H:{format_sig_figs(avg_h)}") + + # Construct compact tag: [L:1600, H:22] + env_tag = f" [{', '.join(env_parts)}]" if env_parts else "" + + final_text.append( + f"- {start_s} to {end_s} ({entry['duration']} mins): {room_name}{env_tag}" + ) + + return "\n".join(final_text) + +# --------------------------------------------------------- +# HELPER: AI MODEL SELECTION +# --------------------------------------------------------- + +def is_ollama_running(): + """Checks if Ollama is running on the default local port.""" + try: + with urllib.request.urlopen("http://localhost:11434", timeout=0.5) as response: + return response.status == 200 + except: + return False + +def select_model_configuration(): + """Interactively select the model provider via CLI.""" + print("\n--- SELECT AI MODEL ---") + + available_keys = [] + + for key, config in MODEL_OPTIONS.items(): + # Check availability for local models + if config['type'] == 'ollama': + if is_ollama_running(): + print(f"[{key}] {config['name']}") + available_keys.append(key) + else: + continue + else: + # Always show cloud providers + print(f"[{key}] {config['name']}") + available_keys.append(key) + + if not available_keys: + print("Error: No models available. (Ollama is offline and no cloud keys configured?)") + sys.exit() + + while True: + choice = input("\nSelect Model # (or 'q'): ") + if choice.lower() == 'q': sys.exit() + if choice in available_keys: + return MODEL_OPTIONS[choice] + print("Invalid selection.") + + +# --------------------------------------------------------- +# HELPER: FILE EXTRACTION +# --------------------------------------------------------- + +def extract_csv(api_response): + """ + Handles the API response. If it's a ZIP file, extracts CSVs. + Returns a list of file paths and a list of extracted dates. + """ + # Check for ZIP Magic Bytes (PK...) + if api_response.content.startswith(b'PK'): + print(f"✅ Received ZIP file. Extracting and saving individual files from it...\n") + + # 1. Load binary content + zip_buffer = io.BytesIO(api_response.content) + + # 2. Define extraction path + extract_folder = os.path.join(os.getcwd(), "data_downloads") + os.makedirs(extract_folder, exist_ok=True) + filename_list = [] + extracted_dates = [] + + # 3. Open and Extract + with zipfile.ZipFile(zip_buffer) as z: + file_list = z.namelist() + for filename in file_list: + if filename.endswith('.csv'): + z.extract(filename, extract_folder) + csv_file_path = os.path.join(extract_folder, filename) + if DEBUG > 0: + print(f"Saved: {csv_file_path}") + filename_list.append(csv_file_path) + + # Extract date from filename (e.g., "21_2025-12-15_by_minute_rc_data.csv") + # Split by '_' and take index 1 + date_part = filename.split('_')[1] + extracted_dates.append(date_part) + + if len(filename_list) > 0: + return filename_list, extracted_dates + else: + return "Error: could not extract the list of csv file names." + else: + # Fallback for JSON error messages + try: + return api_response.json() + except: + return f"Error: Unexpected response format. Start of content: {api_response.content[:20]}" + + +# --------------------------------------------------------- +# CLASS: WELLNUO API WRAPPER +# --------------------------------------------------------- +class WellnuoAPI: + def __init__(self, api_token): + self.api_token = api_token + if not self.api_token: + return "Error: No API Token available." + self.url = "https://eluxnetworks.net/function/well-api/api" + + def download(self, start_date_str, end_date_str): + """Downloads radar data as ZIP file for a date range.""" + payload = { + "name": "download", + "user_name": WELLNUO_USER, + "token": self.api_token, + "deployment_id": "21", + "date_from": start_date_str, + "date_to": end_date_str, + "group_by": "by_minute_rd", + "re_create": "false", + "radar_part": "s28" + } + try: + print(f"\nCalling Wellnuo download() API function to get radar sensor data between {start_date_str} and {end_date_str}...") + response = requests.get(self.url, params=payload) + response.raise_for_status() + return response + except Exception as e: + return f"API Error: {e}" + + def get_sensor_data(self, start_date_str, end_date_str, sensor_type): + """ + Retrieves sensor data "light" or "humidity" for a specific date. + Matches the 'get_sensor_data_by_deployment_id' API function shown in Postman. + """ + payload = { + "function": "get_sensor_data_by_deployment_id", + "user_name": WELLNUO_USER, + "token": self.api_token, + "sensor": sensor_type, + "deployment_id": "21", + "data_type": "ML", + "radar_part": "s28", + "date": start_date_str, + "to_date": end_date_str, + } + + try: + print(f"\n--- Calling Wellnuo get_sensor_data_by_deployment_id() API function to get {sensor_type} sensor data between {start_date_str} and {end_date_str}...") + response = requests.post(self.url, data=payload) + response.raise_for_status() + return response.json() + except Exception as e: + return f"API Error: {e}" + + def get_presence_data(self, start_date_str, end_date_str): + """Retrieves presence data (Z-graph).""" + payload = { + "function": "get_presence_data", + "user_name": WELLNUO_USER, + "token": self.api_token, + "deployment_id": "21", + "date": start_date_str, + "filter": "6", + "data_type": "z-graph", + "to_date": end_date_str, + "refresh": "0" + } + + try: + print(f"\n--- Calling Wellnuo get_presence_data() API function for start_date = {start_date_str}, end_date = {end_date_str} ---") + response = requests.post(self.url, data=payload) + response.raise_for_status() + return response.json() + except Exception as e: + return f"API Error: {e}" + + +# --------------------------------------------------------- +# CLASS: AI INTERPRETER AGENT +# --------------------------------------------------------- + +class ActivityAgent: + def __init__(self, timelines_dict, model_name, extra_context=""): + self.context = self.summarize_timelines(timelines_dict) + + # Append the extra context (humidity list) to the end of the log + if extra_context: + self.context += f"\n\n{extra_context}" + + print("AI agent received the following context: ") + print(self.context) + + self.model_name = model_name + self.system_prompt = """ + You are an expert Health Data Analyst. + You are receiving a timeline of activities of a person in a house. + Answer user's question strictly based on the provided timeline of activities. + If the user mentions 'bathroom' without specifying 'Main' or 'Guest', you must combine the data from both 'Bathroom Main' + and 'Bathroom Guest' and treat them as a single location. + + The timeline entries include environmental data in brackets: [L: value, H: value]. + - L represents the average Light level in Lux. + - H represents the average Humidity percentage. + + These values are averages for the specific time interval and are rounded to two significant figures. + + To identify showers: + 1. Consult the 'Sorted list of average Bathroom Humidity levels' in the Reference Humidity Data. + 2. Identify the main cluster of lower values (the baseline/median range). + 3. Infer 'showering' ONLY for visits that meet **all** of the following criteria: + - The Humidity [H] falls into a higher cluster separated from the baseline by a distinct numeric gap (missing integers in the sorted list). + - The duration of the visit is **at least 7 minutes**. + - The Light [L] level is **at least 1000 Lux**. + 4. **Maximum one shower per day**: If multiple visits on the same day meet the above criteria, identify only the single event with the highest Humidity [H] as the shower. + """ + + print(f"\n and is given the following system prompt:\n") + print(f'"{self.system_prompt}"') + + def summarize_timelines(self, timelines): + """Combines multiple daily timelines into a single string for the LLM context.""" + summary_parts = [] + sorted_dates = sorted(timelines.keys()) + + if sorted_dates: + last_date = sorted_dates[-1] + + for date in sorted_dates: + daily_text = timelines[date] + + # Header formatting + if date == last_date: + header = f"### ACTIVITY LOG FOR: {date} (TODAY)" + else: + header = f"### ACTIVITY LOG FOR: {date}" + + day_block = f"{header}\n{daily_text}" + summary_parts.append(day_block) + + return "\n" + "\n\n".join(summary_parts) + + def ask(self, question): + """Sends the user question and timeline context to the LLM.""" + try: + start_time = time.time() + response = client.chat.completions.create( + model=self.model_name, + messages=[ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": f"Here is the log:\n{self.context}\n\nQuestion: {question}"} + ], + temperature=0.1, + ) + end_time = time.time() + duration = end_time - start_time + print(f"Thought for {duration:.2f} seconds") + return response.choices[0].message.content + + except Exception as e: + return f"Error: {e}" + + +# --------------------------------------------------------- +# MAIN EXECUTION +# --------------------------------------------------------- + +if __name__ == "__main__": + # Initialize Wellnuo API + wellnuo_token = get_wellnuo_credentials() + wellnuo_api = WellnuoAPI(wellnuo_token) + + # Determine Date Range (Last 7 Days) + now = datetime.datetime.now() + today_str = now.strftime("%Y-%m-%d") + lookback = 7 + start_date = now - datetime.timedelta(days=lookback) + start_date_str = start_date.strftime("%Y-%m-%d") + + # Download Light Data + start_time = time.time() + light_data_dict = wellnuo_api.get_sensor_data(start_date_str, today_str, sensor_type="light") + end_time = time.time() + duration = end_time - start_time + print(f"Spent {duration:.2f} seconds obtaining light data from Wellnuo API\n") + # Process light data into a Dictionary: {'2025-12-09': df, '2025-12-10': df...} + daily_light_dfs = preprocess_sensor_data(light_data_dict) + # print_sensor_statistics(daily_light_dfs, sensor_type="Light", unit=" Lux") + + # Download Humidity Data + start_time = time.time() + humidity_data_dict = wellnuo_api.get_sensor_data(start_date_str, today_str, sensor_type="humidity") + end_time = time.time() + duration = end_time - start_time + print(f"Spent {duration:.2f} seconds obtaining humidity data from Wellnuo API\n") + # Process humidty data into a Dictionary: {'2025-12-09': df, '2025-12-10': df...} + daily_humidity_dfs = preprocess_sensor_data(humidity_data_dict) + # print_sensor_statistics(daily_humidity_dfs, sensor_type="Humidity", unit="%") + + # Download Radar Data + start_time = time.time() + zipped_radar_data = wellnuo_api.download(start_date_str, today_str) + end_time = time.time() + duration = end_time - start_time + print(f"Spent {duration:.2f} seconds obtaining radar data from Wellnuo API\n") + # Extract and Process CSV radar files + filename_list, date_list = extract_csv(zipped_radar_data) + + # Generate Timelines + print(f"Creating timelines for dates between {date_list[0]} and {date_list[-1]}\n") + timelines = {} + for filename, date in zip(filename_list, date_list): + # Retrieve the specific light DataFrame for this date (if it exists) + daily_light_df = daily_light_dfs.get(date) + daily_humidity_df = daily_humidity_dfs.get(date) + + # Pass the specific day's light data to the timeline creator + timelines[date] = create_timeline(filename, light_df=daily_light_df, humidity_df=daily_humidity_df) + + # Select AI Model + model_config = select_model_configuration() + + # Initialize OpenAI Client + print(f"\nInitializing {model_config['name']}...") + client = OpenAI( + base_url=model_config['base_url'], + api_key=model_config['api_key'] + ) + + # Generate the humidity profile string + humidity_context = get_timeline_humidity_profile(timelines) + # Start Agent + agent = ActivityAgent(timelines, model_config['model_name'], extra_context=humidity_context) + print(f"\n--- Smart Agent Ready ({model_config['name']}) ---") + + # Interactive Loop + while True: + q = input("\nYou: ") + if q.lower() in ['q', 'exit']: break + print("Thinking...") + print(f"Agent: {agent.ask(q)}") \ No newline at end of file