diff --git a/models/recommendation.py b/models/recommendation.py index 15448d3..eafe169 100644 --- a/models/recommendation.py +++ b/models/recommendation.py @@ -1,37 +1,182 @@ +""" +Plant recommendation engine with fuzzy matching and optional +light/temperature data import from .bin / .npz files. +""" + +import numpy as np +import os from database.plants import df_plants +# ---------------------------------------------------------------------- +# Original fuzzy‑matching helpers +# ---------------------------------------------------------------------- def _triangular_membership(x, center, spread): - if spread <= 0: return 1.0 if x == center else 0.0 - return max(0.0, 1.0 - abs(x-center)/spread) + if spread <= 0: + return 1.0 if x == center else 0.0 + return max(0.0, 1.0 - abs(x - center) / spread) def _fuzzy_match(user_val, plant_val, spread): return _triangular_membership(plant_val, user_val, spread) +# ---------------------------------------------------------------------- +# Public recommendation function +# ---------------------------------------------------------------------- def recommend_plants(user_prefs, top_n=5): - if df_plants is None: return [] + """ + Return a list of (plant_name, score) tuples sorted by score. + user_prefs is a dict that may contain: + water, sunlight, temp, pet_safe, space, + allergy_concern, existing_plants + """ + if df_plants is None: + return [] + scores = [] for _, plant in df_plants.iterrows(): - w_match = _fuzzy_match(user_prefs["water"], plant["water"], 2.0) - s_match = _fuzzy_match(user_prefs["sunlight"], plant["sunlight"], 2.0) - t_match = _fuzzy_match(user_prefs["temp"], plant["temperature"], 4.0) - p_match = (1.0 if plant["pet_safe"] == user_prefs["pet_safe"] else 0.0) if user_prefs.get("pet_safe") is not None else None - space_match = (1.0 if plant["space"] == user_prefs["space"] else 0.0) if user_prefs.get("space") is not None else None + w_match = _fuzzy_match(user_prefs["water"], plant["water"], 2.0) + s_match = _fuzzy_match(user_prefs["sunlight"], plant["sunlight"], 2.0) + t_match = _fuzzy_match(user_prefs["temp"], plant["temperature"], 4.0) + + p_match = (1.0 if plant["pet_safe"] == user_prefs["pet_safe"] else 0.0) \ + if user_prefs.get("pet_safe") is not None else None + + space_match = (1.0 if plant["space"] == user_prefs["space"] else 0.0) \ + if user_prefs.get("space") is not None else None + if user_prefs.get("allergy_concern") is not None: - a_match = (1.0 if not plant["pollen_allergies"] else 0.0) if user_prefs["allergy_concern"] else 1.0 + a_match = (1.0 if not plant["pollen_allergies"] else 0.0) \ + if user_prefs["allergy_concern"] else 1.0 else: a_match = None + user_existing = user_prefs.get("existing_plants", []) if user_existing: plant_compat_list = plant["existing_plants"] - exist_match = (sum(1 for up in user_existing if up in plant_compat_list)/len(user_existing)) if plant_compat_list else 0.0 + exist_match = (sum(1 for up in user_existing if up in plant_compat_list) / + len(user_existing)) if plant_compat_list else 0.0 else: exist_match = None - weights = {"water":0.15,"sunlight":0.15,"temp":0.15,"pet":0.15,"space":0.15,"allergy":0.15,"existing":0.10} - components = {"water":w_match,"sunlight":s_match,"temp":t_match,"pet":p_match,"space":space_match,"allergy":a_match,"existing":exist_match} - active = {k:v for k,v in components.items() if v is not None} - if not active: continue + + weights = { + "water": 0.15, "sunlight": 0.15, "temp": 0.15, + "pet": 0.15, "space": 0.15, "allergy": 0.15, "existing": 0.10 + } + components = { + "water": w_match, "sunlight": s_match, "temp": t_match, + "pet": p_match, "space": space_match, "allergy": a_match, + "existing": exist_match + } + active = {k: v for k, v in components.items() if v is not None} + if not active: + continue + active_weight_sum = sum(weights[k] for k in active) - score = sum(v*weights[k]/active_weight_sum for k,v in active.items()) + score = sum(v * weights[k] / active_weight_sum for k, v in active.items()) scores.append((plant["name"], score)) + scores.sort(key=lambda x: x[1], reverse=True) - return scores[:top_n] \ No newline at end of file + return scores[:top_n] + +# ---------------------------------------------------------------------- +# New helper: map lux readings to a sunlight score (1–10) +# ---------------------------------------------------------------------- +def _lux_to_sunlight(lux): + """ + Convert an average lux value into a plant‑care sunlight scale 1–10. + Thresholds are loosely based on indoor light levels. + """ + # Piecewise linear mapping: + thresholds = [ + (0, 100, 1.0, 2.0), + (100, 250, 2.0, 3.0), + (250, 500, 3.0, 4.0), + (500, 1000, 4.0, 5.0), + (1000, 2000, 5.0, 6.0), + (2000, 4000, 6.0, 7.0), + (4000, 8000, 7.0, 8.0), + (8000, 16000, 8.0, 9.0), + (16000, 32000, 9.0, 10.0), + (32000, float("inf"), 10.0, 10.0), + ] + for low, high, low_s, high_s in thresholds: + if low <= lux <= high: + if high == float("inf"): + return low_s + fraction = (lux - low) / (high - low) + return low_s + fraction * (high_s - low_s) + return 5.0 # fallback + +# ---------------------------------------------------------------------- +# File‑analysis entry point +# ---------------------------------------------------------------------- +def analyze_environment_file(file_path): + """ + Read a .bin or .npz file that may contain light and/or temperature data. + + Returns a dict with: + - "sunlight" : float (1‑10) derived from average light + - "temp" : float or None (average temperature in °C) + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + ext = os.path.splitext(file_path)[1].lower() + light_values = [] + temp_values = [] + + try: + if ext == ".npz": + with np.load(file_path, allow_pickle=True) as npz: + # Look for known keys + for key in npz.files: + arr = npz[key].flatten() + if key.lower() in ("light", "lux", "illuminance"): + light_values.extend(arr.tolist()) + elif key.lower() in ("temp", "temperature", "celsius"): + temp_values.extend(arr.tolist()) + # If no known keys, treat everything as light (back‑compat with dashboard) + if not light_values and not temp_values: + for key in npz.files: + light_values.extend(npz[key].flatten().tolist()) + + elif ext == ".bin": + raw = np.fromfile(file_path, dtype=np.float32) + light_values = raw.tolist() + else: + raise ValueError("Unsupported file type. Use .bin or .npz") + + except Exception as e: + raise RuntimeError(f"Failed to read environment file: {e}") + + # Compute sunlight from light data + sunlight = None + if light_values: + avg_lux = sum(light_values) / len(light_values) + sunlight = _lux_to_sunlight(avg_lux) + else: + sunlight = 5.0 # neutral guess if no light data + + # Compute temperature if available + temp = None + if temp_values: + avg_temp = sum(temp_values) / len(temp_values) + temp = round(avg_temp, 1) + + return {"sunlight": sunlight, "temp": temp} + +# ---------------------------------------------------------------------- +# Convenience function that merges file‑derived values into prefs +# ---------------------------------------------------------------------- +def merge_file_prefs(user_prefs, file_path): + """ + Return a new user_prefs dict where 'sunlight' and 'temp' are + replaced with values from the file, if available. + Other keys are left untouched. + """ + env = analyze_environment_file(file_path) + merged = dict(user_prefs) # shallow copy + merged["sunlight"] = env["sunlight"] + if env["temp"] is not None: + merged["temp"] = env["temp"] + # If no temperature was found, keep the original temp value + return merged \ No newline at end of file diff --git a/ui/pages/recommendation.py b/ui/pages/recommendation.py index 3c2053a..1df4c69 100644 --- a/ui/pages/recommendation.py +++ b/ui/pages/recommendation.py @@ -1,11 +1,13 @@ import tkinter as tk +from tkinter import filedialog, messagebox +import os from .theme import ( BG_MAIN, BG_CARD, BG_CARD2, BG_GLASS, ACCENT, ACCENT2, BLUE, RED, YELLOW, TEXT_PRI, TEXT_SEC, TEXT_MUT, BORDER, ON_ACCENT, bind_tree, hover, GreenSlider, BasePage ) from database.plants import df_plants as _df_plants, PANDAS_OK -from models.recommendation import recommend_plants +from models.recommendation import recommend_plants, analyze_environment_file class RecommendationSystemPage(BasePage): @@ -59,6 +61,24 @@ def slider_row(parent, label, from_, to, resolution, default): self._sunlight_var = slider_row(left_inner, "Sunlight (1–10)", 1, 10, 0.5, 6) self._temp_var = slider_row(left_inner, "Temperature (°C)", 10, 40, 0.5, 22) + # ----- File import for light / temperature (overrides sliders) ----- + file_frame = tk.Frame(left_inner, bg=BG_CARD) + file_frame.pack(fill="x", pady=(4, 0)) + tk.Label(file_frame, text="Or load from light data file:", + font=self.f_small, bg=BG_CARD, fg=TEXT_SEC).pack(anchor="w") + btn_row = tk.Frame(file_frame, bg=BG_CARD); btn_row.pack(fill="x", pady=(4,0)) + file_btn = tk.Frame(btn_row, bg=ACCENT, cursor="hand2", padx=12, pady=5) + file_btn.pack(side="left") + tk.Label(file_btn, text="📂 Choose File", font=self.f_label, + bg=ACCENT, fg=ON_ACCENT).pack() + bind_tree(file_btn, "", lambda e: self._load_light_file()) + hover(file_btn, ACCENT, ACCENT2) + self._file_info_var = tk.StringVar(value="") + tk.Label(btn_row, textvariable=self._file_info_var, + font=("Segoe UI",8), bg=BG_CARD, fg=TEXT_MUT, + wraplength=200, justify="left").pack(side="left", padx=(8,0)) + # ------------------------------------------------------------------ + tk.Frame(left_inner, bg=BORDER, height=1).pack(fill="x", pady=(8,10)) # Space selector @@ -138,6 +158,28 @@ def toggle(e, v=var, l=lbl): self._results_frame.pack(fill="both", expand=True) self._show_finder_empty() + def _load_light_file(self): + """Let the user pick a .bin or .npz file and update sunlight / temp sliders.""" + path = filedialog.askopenfilename( + title="Select Light Data File", + filetypes=[("Binary/NumPy files", "*.bin *.npz"), ("All files", "*.*")] + ) + if not path: + return + try: + env = analyze_environment_file(path) + # Update sliders – values will be clamped to their ranges automatically + self._sunlight_var.set(env["sunlight"]) + if env["temp"] is not None: + self._temp_var.set(env["temp"]) + # Show what was loaded + temp_str = f"{env['temp']}°C" if env["temp"] is not None else "—" + self._file_info_var.set( + f"✔ {os.path.basename(path)}\nLight: {env['sunlight']:.1f} Temp: {temp_str}" + ) + except Exception as e: + messagebox.showerror("File Error", f"Could not read file:\n{e}") + def _show_finder_empty(self): for w in self._results_frame.winfo_children(): w.destroy() container = tk.Frame(self._results_frame, bg=BG_CARD, padx=20, pady=40)