Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- modules/__pycache__/classifier.cpython-313.pyc +0 -0
- modules/__pycache__/pixel_art.cpython-313.pyc +0 -0
- modules/__pycache__/plant.cpython-313.pyc +0 -0
- modules/__pycache__/plant.cpython-314.pyc +0 -0
- modules/__pycache__/recommender.cpython-313.pyc +0 -0
- modules/__pycache__/watering.cpython-313.pyc +0 -0
- modules/__pycache__/watering.cpython-314.pyc +0 -0
- modules/__pycache__/weather.cpython-313.pyc +0 -0
- modules/__pycache__/weather.cpython-314.pyc +0 -0
- modules/__pycache__/weather_utils.cpython-313.pyc +0 -0
- modules/__pycache__/weather_utils.cpython-314.pyc +0 -0
- modules/classifier.py +34 -0
- modules/pixel_art.py +214 -0
- modules/plant.py +59 -0
- modules/recommender.py +50 -0
- modules/test.ipynb +45 -0
- modules/watering.py +119 -0
- modules/weather.py +8 -0
- modules/weather_test.ipynb +242 -0
- modules/weather_utils.py +200 -0
modules/__pycache__/classifier.cpython-313.pyc
ADDED
|
Binary file (1.88 kB). View file
|
|
|
modules/__pycache__/pixel_art.cpython-313.pyc
ADDED
|
Binary file (7.97 kB). View file
|
|
|
modules/__pycache__/plant.cpython-313.pyc
ADDED
|
Binary file (3.89 kB). View file
|
|
|
modules/__pycache__/plant.cpython-314.pyc
ADDED
|
Binary file (4.12 kB). View file
|
|
|
modules/__pycache__/recommender.cpython-313.pyc
ADDED
|
Binary file (2.32 kB). View file
|
|
|
modules/__pycache__/watering.cpython-313.pyc
ADDED
|
Binary file (4.15 kB). View file
|
|
|
modules/__pycache__/watering.cpython-314.pyc
ADDED
|
Binary file (5.06 kB). View file
|
|
|
modules/__pycache__/weather.cpython-313.pyc
ADDED
|
Binary file (888 Bytes). View file
|
|
|
modules/__pycache__/weather.cpython-314.pyc
ADDED
|
Binary file (1.12 kB). View file
|
|
|
modules/__pycache__/weather_utils.cpython-313.pyc
ADDED
|
Binary file (6.84 kB). View file
|
|
|
modules/__pycache__/weather_utils.cpython-314.pyc
ADDED
|
Binary file (6.75 kB). View file
|
|
|
modules/classifier.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 6 |
+
|
| 7 |
+
CLASSIFIER_MODEL_ID = os.getenv("CLASSIFIER_MODEL_ID", "your-username/plant-genus-classifier")
|
| 8 |
+
|
| 9 |
+
_processor = None
|
| 10 |
+
_model = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _load():
|
| 14 |
+
global _processor, _model
|
| 15 |
+
if _model is None:
|
| 16 |
+
_processor = AutoImageProcessor.from_pretrained(CLASSIFIER_MODEL_ID)
|
| 17 |
+
_model = AutoModelForImageClassification.from_pretrained(CLASSIFIER_MODEL_ID)
|
| 18 |
+
_model.eval()
|
| 19 |
+
return _processor, _model
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def classify_plant(image: Image.Image) -> tuple[str, float]:
|
| 23 |
+
"""Run the fine-tuned genus classifier on an uploaded image.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
(genus_name, confidence_score)
|
| 27 |
+
"""
|
| 28 |
+
processor, model = _load()
|
| 29 |
+
inputs = processor(images=image.convert("RGB"), return_tensors="pt")
|
| 30 |
+
with torch.no_grad():
|
| 31 |
+
logits = model(**inputs).logits
|
| 32 |
+
probs = torch.softmax(logits, dim=-1)
|
| 33 |
+
top_id = int(probs.argmax(dim=-1))
|
| 34 |
+
return model.config.id2label[top_id], probs[0, top_id].item()
|
modules/pixel_art.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Procedural pixel-art sprites for plants and the app favicon.
|
| 2 |
+
|
| 3 |
+
Each sprite is built from a 16x16 grid: a 10-row plant "archetype" stacked on
|
| 4 |
+
top of a 6-row pot. Rows are authored as 8-character halves and mirrored
|
| 5 |
+
(`half + half[::-1]`) to get a symmetric 16-wide row, then the whole grid is
|
| 6 |
+
upscaled with nearest-neighbour resizing for a crisp pixel-art look.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import functools
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
GROWTH_CSV = "data/growth_csv/growth_ds.csv"
|
| 16 |
+
STATIC_DIR = Path("static")
|
| 17 |
+
SPRITES_DIR = STATIC_DIR / "sprites"
|
| 18 |
+
FAVICON_PATH = STATIC_DIR / "favicon.png"
|
| 19 |
+
|
| 20 |
+
# ── Palette ──────────────────────────────────────────────────────────────────
|
| 21 |
+
PALETTE = {
|
| 22 |
+
".": (0, 0, 0, 0),
|
| 23 |
+
"G": (46, 125, 50, 255), # dark leaf green
|
| 24 |
+
"g": (102, 187, 106, 255), # mid green
|
| 25 |
+
"l": (197, 225, 165, 255), # light green / highlight
|
| 26 |
+
"y": (255, 213, 79, 255), # yellow flower
|
| 27 |
+
"o": (255, 152, 0, 255), # orange flower center
|
| 28 |
+
"t": (121, 85, 72, 255), # trunk brown
|
| 29 |
+
"S": (109, 76, 65, 255), # soil
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
POT_STYLES = {
|
| 33 |
+
"terracotta": {"P": (216, 124, 90, 255), "Q": (173, 96, 68, 255), "R": (235, 165, 138, 255)},
|
| 34 |
+
"white": {"P": (245, 245, 245, 255), "Q": (210, 210, 210, 255), "R": (255, 255, 255, 255)},
|
| 35 |
+
"ceramic_blue": {"P": (84, 153, 199, 255), "Q": (55, 109, 148, 255), "R": (165, 214, 237, 255)},
|
| 36 |
+
"charcoal": {"P": (74, 74, 74, 255), "Q": (45, 45, 45, 255), "R": (115, 115, 115, 255)},
|
| 37 |
+
}
|
| 38 |
+
POT_NAMES = list(POT_STYLES.keys())
|
| 39 |
+
|
| 40 |
+
# ── Plant archetypes (10 half-rows, 8 chars each) ───────────────────────────
|
| 41 |
+
PLANT_SPRITES = {
|
| 42 |
+
"cactus": [
|
| 43 |
+
"........",
|
| 44 |
+
"........",
|
| 45 |
+
"......gg",
|
| 46 |
+
"......gG",
|
| 47 |
+
"..gg..gG",
|
| 48 |
+
"..gG..gG",
|
| 49 |
+
"..gg..gG",
|
| 50 |
+
"......gG",
|
| 51 |
+
"......gg",
|
| 52 |
+
"......gl",
|
| 53 |
+
],
|
| 54 |
+
"succulent": [
|
| 55 |
+
"........",
|
| 56 |
+
".......l",
|
| 57 |
+
"......gl",
|
| 58 |
+
".....Ggl",
|
| 59 |
+
"....gGgl",
|
| 60 |
+
"...ggGgl",
|
| 61 |
+
"..gggGgl",
|
| 62 |
+
".ggggGgl",
|
| 63 |
+
"ggggggGl",
|
| 64 |
+
"gggggggl",
|
| 65 |
+
],
|
| 66 |
+
"fern": [
|
| 67 |
+
"........",
|
| 68 |
+
"...gGg..",
|
| 69 |
+
"..glGgl.",
|
| 70 |
+
".gGglGg.",
|
| 71 |
+
"gGlgGgl.",
|
| 72 |
+
"gglGglg.",
|
| 73 |
+
".gGlGg..",
|
| 74 |
+
"...g.g..",
|
| 75 |
+
"....g...",
|
| 76 |
+
"....gg..",
|
| 77 |
+
],
|
| 78 |
+
"flower": [
|
| 79 |
+
"......y.",
|
| 80 |
+
".....yoy",
|
| 81 |
+
"......y.",
|
| 82 |
+
".......g",
|
| 83 |
+
"..g....g",
|
| 84 |
+
".gG....g",
|
| 85 |
+
"..g....g",
|
| 86 |
+
".......g",
|
| 87 |
+
".......g",
|
| 88 |
+
"......gg",
|
| 89 |
+
],
|
| 90 |
+
"palm": [
|
| 91 |
+
"..l...l.",
|
| 92 |
+
".gg...gg",
|
| 93 |
+
"..gGGg..",
|
| 94 |
+
"...g.g..",
|
| 95 |
+
".......t",
|
| 96 |
+
".......t",
|
| 97 |
+
".......t",
|
| 98 |
+
".......t",
|
| 99 |
+
"......tt",
|
| 100 |
+
"......tt",
|
| 101 |
+
],
|
| 102 |
+
"trailing": [
|
| 103 |
+
"........",
|
| 104 |
+
".gGggGl.",
|
| 105 |
+
"gGggGgl.",
|
| 106 |
+
"Gggggl..",
|
| 107 |
+
"gl......",
|
| 108 |
+
"Gl......",
|
| 109 |
+
"gl......",
|
| 110 |
+
"Gl......",
|
| 111 |
+
"gl......",
|
| 112 |
+
"gl......",
|
| 113 |
+
],
|
| 114 |
+
}
|
| 115 |
+
ARCHETYPES = list(PLANT_SPRITES.keys())
|
| 116 |
+
|
| 117 |
+
# ── Pot (6 half-rows, 8 chars each) ─────────────────────────────────────────
|
| 118 |
+
POT_BASE = [
|
| 119 |
+
"...SSSSS",
|
| 120 |
+
"..RPPPPP",
|
| 121 |
+
"..QPPPPP",
|
| 122 |
+
"...QPPPP",
|
| 123 |
+
"....QPPP",
|
| 124 |
+
".....QPP",
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ── Genus -> (archetype, pot) mapping ───────────────────────────────────────
|
| 129 |
+
@functools.lru_cache(maxsize=1)
|
| 130 |
+
def _growth_table() -> pd.DataFrame:
|
| 131 |
+
return pd.read_csv(GROWTH_CSV)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _stable_hash(text: str) -> int:
|
| 135 |
+
"""Deterministic hash (stable across runs, unlike builtin hash() for str)."""
|
| 136 |
+
h = 0
|
| 137 |
+
for ch in text:
|
| 138 |
+
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
|
| 139 |
+
return h
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def genus_to_sprite_key(genus: str) -> tuple[str, str]:
|
| 143 |
+
"""Map a genus to a deterministic (plant archetype, pot style) pair."""
|
| 144 |
+
df = _growth_table()
|
| 145 |
+
row = df[df["Genus"] == genus]
|
| 146 |
+
|
| 147 |
+
if row.empty:
|
| 148 |
+
h = _stable_hash(genus)
|
| 149 |
+
archetype = ARCHETYPES[h % len(ARCHETYPES)]
|
| 150 |
+
pot = POT_NAMES[(h // len(ARCHETYPES)) % len(POT_NAMES)]
|
| 151 |
+
return archetype, pot
|
| 152 |
+
|
| 153 |
+
growth = str(row["Growth"].iloc[0]).lower()
|
| 154 |
+
soil = str(row["Soil"].iloc[0]).lower()
|
| 155 |
+
sunlight = str(row["Sunlight"].iloc[0]).lower()
|
| 156 |
+
|
| 157 |
+
if "sandy" in soil and "full" in sunlight:
|
| 158 |
+
archetype = "cactus" if growth == "slow" else "succulent"
|
| 159 |
+
elif "indirect" in sunlight and growth in ("slow", "moderate"):
|
| 160 |
+
archetype = "fern" if ("well-drained" in soil or "loamy" in soil) else "trailing"
|
| 161 |
+
elif growth == "fast" and "full" in sunlight:
|
| 162 |
+
archetype = "flower"
|
| 163 |
+
elif growth == "slow" and "well-drained" in soil:
|
| 164 |
+
archetype = "palm"
|
| 165 |
+
else:
|
| 166 |
+
archetype = "fern"
|
| 167 |
+
|
| 168 |
+
pot = {"sandy": "terracotta", "well-drained": "white", "loamy": "charcoal"}.get(soil, "ceramic_blue")
|
| 169 |
+
return archetype, pot
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ── Rendering ────────────────────────────────────────────────────────────────
|
| 173 |
+
def _build_image(half_rows: list[str], palette: dict) -> Image.Image:
|
| 174 |
+
rows = [half + half[::-1] for half in half_rows]
|
| 175 |
+
size = len(rows)
|
| 176 |
+
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
| 177 |
+
for y, row in enumerate(rows):
|
| 178 |
+
for x, ch in enumerate(row):
|
| 179 |
+
img.putpixel((x, y), palette.get(ch, (0, 0, 0, 0)))
|
| 180 |
+
return img
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def render_sprite(genus: str, scale: int = 8) -> Image.Image:
|
| 184 |
+
"""Render the pixel-art (plant + pot) sprite for a genus."""
|
| 185 |
+
archetype, pot_style = genus_to_sprite_key(genus)
|
| 186 |
+
half_rows = PLANT_SPRITES[archetype] + POT_BASE
|
| 187 |
+
palette = {**PALETTE, **POT_STYLES[pot_style]}
|
| 188 |
+
img = _build_image(half_rows, palette)
|
| 189 |
+
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _slugify(genus: str) -> str:
|
| 193 |
+
return "".join(ch.lower() if ch.isalnum() else "_" for ch in genus)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def get_sprite_path(genus: str) -> str:
|
| 197 |
+
"""Return the path to the (lazily rendered + cached) sprite for a genus."""
|
| 198 |
+
SPRITES_DIR.mkdir(parents=True, exist_ok=True)
|
| 199 |
+
path = SPRITES_DIR / f"{_slugify(genus)}.png"
|
| 200 |
+
if not path.exists():
|
| 201 |
+
render_sprite(genus).save(path)
|
| 202 |
+
return str(path)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def ensure_favicon() -> str:
|
| 206 |
+
"""Return the path to the (lazily rendered + cached) app favicon."""
|
| 207 |
+
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
| 208 |
+
if not FAVICON_PATH.exists():
|
| 209 |
+
half_rows = PLANT_SPRITES["fern"] + POT_BASE
|
| 210 |
+
palette = {**PALETTE, **POT_STYLES["terracotta"]}
|
| 211 |
+
img = _build_image(half_rows, palette)
|
| 212 |
+
img = img.resize((img.width * 4, img.height * 4), Image.NEAREST)
|
| 213 |
+
img.save(FAVICON_PATH)
|
| 214 |
+
return str(FAVICON_PATH)
|
modules/plant.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
class Plant:
|
| 3 |
+
def __init__(self, genus):
|
| 4 |
+
self.genus = genus
|
| 5 |
+
self.db_path = './data/growth_csv/growth_ds.csv'
|
| 6 |
+
self.watering_frequency = self.get_watering_frequency()
|
| 7 |
+
self.plant_name = self.get_plant_name()
|
| 8 |
+
self.sunlight = self.get_sunlight_requirements()
|
| 9 |
+
self.soil_type = self.get_soil_type()
|
| 10 |
+
self.fertilization_type = self.get_fertilization_type()
|
| 11 |
+
self.last_watered = None # Track the last watered date
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def __str__(self):
|
| 15 |
+
return f"Plant(genus={self.genus}, plant_name={self.plant_name}, watering_frequency={self.watering_frequency}, sunlight={self.sunlight}, soil_type={self.soil_type}, fertilization_type={self.fertilization_type})"
|
| 16 |
+
|
| 17 |
+
def get_watering_frequency(self):
|
| 18 |
+
# import csv
|
| 19 |
+
growth_csv = pd.read_csv(self.db_path)
|
| 20 |
+
plant_data = growth_csv[growth_csv['Genus'] == self.genus]
|
| 21 |
+
if not plant_data.empty:
|
| 22 |
+
return plant_data['Watering'].iloc[0]
|
| 23 |
+
|
| 24 |
+
return None # Return None if no data found for the genus
|
| 25 |
+
|
| 26 |
+
def get_plant_name(self):
|
| 27 |
+
# import csv
|
| 28 |
+
growth_csv = pd.read_csv(self.db_path)
|
| 29 |
+
plant_data = growth_csv[growth_csv['Genus'] == self.genus]
|
| 30 |
+
if not plant_data.empty:
|
| 31 |
+
return plant_data['Plant Name'].iloc[0]
|
| 32 |
+
return None # Return None if no data found for the genus
|
| 33 |
+
|
| 34 |
+
def get_sunlight_requirements(self):
|
| 35 |
+
# import csv
|
| 36 |
+
growth_csv = pd.read_csv(self.db_path)
|
| 37 |
+
plant_data = growth_csv[growth_csv['Genus'] == self.genus]
|
| 38 |
+
if not plant_data.empty:
|
| 39 |
+
return plant_data['Sunlight'].iloc[0]
|
| 40 |
+
return None # Return None if no data found for the genus
|
| 41 |
+
|
| 42 |
+
def get_soil_type(self):
|
| 43 |
+
# import csv
|
| 44 |
+
growth_csv = pd.read_csv(self.db_path)
|
| 45 |
+
plant_data = growth_csv[growth_csv['Genus'] == self.genus]
|
| 46 |
+
if not plant_data.empty:
|
| 47 |
+
return plant_data['Soil'].iloc[0]
|
| 48 |
+
return None # Return None if no data found for the genus
|
| 49 |
+
|
| 50 |
+
def get_fertilization_type(self):
|
| 51 |
+
# import csv
|
| 52 |
+
growth_csv = pd.read_csv(self.db_path)
|
| 53 |
+
plant_data = growth_csv[growth_csv['Genus'] == self.genus]
|
| 54 |
+
if not plant_data.empty:
|
| 55 |
+
return plant_data['Fertilization Type'].iloc[0]
|
| 56 |
+
return None # Return None if no data found for the genus
|
| 57 |
+
|
| 58 |
+
def set_last_watered(self, date):
|
| 59 |
+
self.last_watered = date
|
modules/recommender.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 5 |
+
|
| 6 |
+
RECOMMENDER_MODEL_ID = os.getenv("RECOMMENDER_MODEL_ID", "your-username/plant-care-recommender")
|
| 7 |
+
|
| 8 |
+
_tokenizer = None
|
| 9 |
+
_model = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _load():
|
| 13 |
+
global _tokenizer, _model
|
| 14 |
+
if _model is None:
|
| 15 |
+
_tokenizer = AutoTokenizer.from_pretrained(RECOMMENDER_MODEL_ID)
|
| 16 |
+
_model = AutoModelForCausalLM.from_pretrained(RECOMMENDER_MODEL_ID)
|
| 17 |
+
_model.eval()
|
| 18 |
+
return _tokenizer, _model
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def generate_care_notes(plant_info: dict, plant_name: str | None = None, genus: str | None = None) -> str:
|
| 22 |
+
"""Generate natural-language care tips for a plant.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
plant_info: Care metadata (watering_frequency_days, sunlight, soil, fertilization_type).
|
| 26 |
+
plant_name: Common plant name, if known.
|
| 27 |
+
genus: Genus name, if known.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Generated care notes.
|
| 31 |
+
"""
|
| 32 |
+
tokenizer, model = _load()
|
| 33 |
+
|
| 34 |
+
subject = plant_name or genus or "this plant"
|
| 35 |
+
messages = [{"role": "user", "content": f"Give me care tips for {subject}."}]
|
| 36 |
+
inputs = tokenizer.apply_chat_template(
|
| 37 |
+
messages, add_generation_prompt=True, return_tensors="pt", return_dict=True
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
with torch.no_grad():
|
| 41 |
+
output = model.generate(
|
| 42 |
+
**inputs,
|
| 43 |
+
max_new_tokens=200,
|
| 44 |
+
do_sample=True,
|
| 45 |
+
temperature=0.7,
|
| 46 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
response = tokenizer.decode(output[0, inputs["input_ids"].shape[-1]:], skip_special_tokens=True)
|
| 50 |
+
return response.strip()
|
modules/test.ipynb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 2,
|
| 6 |
+
"id": "e05bbcd8",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"name": "stdout",
|
| 11 |
+
"output_type": "stream",
|
| 12 |
+
"text": [
|
| 13 |
+
"Plant(genus=Ficus, plant_name=Rubber Plant, watering_frequency=Water when soil feels dry, sunlight=indirect sunlight, soil_type=well-drained, fertilization_type=Balanced)\n"
|
| 14 |
+
]
|
| 15 |
+
}
|
| 16 |
+
],
|
| 17 |
+
"source": [
|
| 18 |
+
"from plant import Plant\n",
|
| 19 |
+
"p = Plant(\"Ficus\")\n",
|
| 20 |
+
"print(p)"
|
| 21 |
+
]
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"metadata": {
|
| 25 |
+
"kernelspec": {
|
| 26 |
+
"display_name": "Python 3",
|
| 27 |
+
"language": "python",
|
| 28 |
+
"name": "python3"
|
| 29 |
+
},
|
| 30 |
+
"language_info": {
|
| 31 |
+
"codemirror_mode": {
|
| 32 |
+
"name": "ipython",
|
| 33 |
+
"version": 3
|
| 34 |
+
},
|
| 35 |
+
"file_extension": ".py",
|
| 36 |
+
"mimetype": "text/x-python",
|
| 37 |
+
"name": "python",
|
| 38 |
+
"nbconvert_exporter": "python",
|
| 39 |
+
"pygments_lexer": "ipython3",
|
| 40 |
+
"version": "3.14.5"
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"nbformat": 4,
|
| 44 |
+
"nbformat_minor": 5
|
| 45 |
+
}
|
modules/watering.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from modules.plant import Plant
|
| 5 |
+
from modules.weather_utils import did_or_will_rain, weather_values
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# ── 1. Watering frequency parser ───────────────────────────────────────────────
|
| 9 |
+
|
| 10 |
+
_RECOMMENDATION_MAP: dict[str, int] = {
|
| 11 |
+
# ── Fixed cadence ──────────────────────────────────────────────────────
|
| 12 |
+
"water weekly": 7,
|
| 13 |
+
# ── Always moist ──────────────────────────────────────────────────────
|
| 14 |
+
"keep soil moist": 2,
|
| 15 |
+
"keep soil consistently moist": 2,
|
| 16 |
+
"keep soil evenly moist": 2,
|
| 17 |
+
"keep soil slightly moist": 3,
|
| 18 |
+
"regular, moist soil": 3,
|
| 19 |
+
# ── Regular ───────────────────────────────────────────────────────────
|
| 20 |
+
"regular watering": 4,
|
| 21 |
+
"regular, well-drained soil": 4,
|
| 22 |
+
# ── Water when dry ────────────────────────────────────────────────────
|
| 23 |
+
"water when soil is dry": 5,
|
| 24 |
+
"water when topsoil is dry": 5,
|
| 25 |
+
"water when soil feels dry": 5,
|
| 26 |
+
"let soil dry between watering": 7,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
_FALLBACK_RULES: list[tuple[str, int | None]] = [
|
| 30 |
+
(r"daily", 1),
|
| 31 |
+
(r"twice.{0,10}week", 3),
|
| 32 |
+
(r"every\s+(\d+)\s+day", None), # extracted dynamically
|
| 33 |
+
(r"once.{0,10}week|weekly", 7),
|
| 34 |
+
(r"once.{0,10}fortnight|every.{0,6}two.{0,6}week", 14),
|
| 35 |
+
(r"monthly", 30),
|
| 36 |
+
(r"moist", 2),
|
| 37 |
+
(r"dry", 6),
|
| 38 |
+
(r"regular", 4),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
DEFAULT_INTERVAL = 4
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _parse_watering_frequency(recommendation: str) -> int:
|
| 45 |
+
"""Map a free-text watering recommendation to a watering interval in days.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
recommendation: e.g. "Water weekly", "Keep soil moist"
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Watering interval in days (>= 1).
|
| 52 |
+
"""
|
| 53 |
+
if not recommendation or not recommendation.strip():
|
| 54 |
+
return DEFAULT_INTERVAL
|
| 55 |
+
|
| 56 |
+
cleaned = recommendation.strip().lower()
|
| 57 |
+
|
| 58 |
+
if cleaned in _RECOMMENDATION_MAP:
|
| 59 |
+
return _RECOMMENDATION_MAP[cleaned]
|
| 60 |
+
|
| 61 |
+
for key, days in _RECOMMENDATION_MAP.items():
|
| 62 |
+
if cleaned.startswith(key) or key.startswith(cleaned):
|
| 63 |
+
return days
|
| 64 |
+
|
| 65 |
+
for pattern, days in _FALLBACK_RULES:
|
| 66 |
+
m = re.search(pattern, cleaned)
|
| 67 |
+
if m:
|
| 68 |
+
if days is None:
|
| 69 |
+
try:
|
| 70 |
+
return max(1, int(m.group(1)))
|
| 71 |
+
except (IndexError, ValueError):
|
| 72 |
+
return DEFAULT_INTERVAL
|
| 73 |
+
return days
|
| 74 |
+
|
| 75 |
+
return DEFAULT_INTERVAL
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_watering_frequency(plant: Plant) -> int:
|
| 80 |
+
"""Determine the watering frequency for a plant, in days.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
plant: The plant to determine the watering frequency for.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Watering frequency in days (>= 1).
|
| 87 |
+
"""
|
| 88 |
+
if plant.watering_frequency:
|
| 89 |
+
return _parse_watering_frequency(plant.watering_frequency)
|
| 90 |
+
|
| 91 |
+
return DEFAULT_INTERVAL
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def should_water(plant: Plant,last_watered: datetime.date | None, date: datetime.date, LAT: float, LON: float) -> bool:
|
| 95 |
+
"""Determine if a plant should be watered on a given date.
|
| 96 |
+
# returns true if its not raining and the plant wasnt watered since the last watering frequency interval
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
plant: The plant to check.
|
| 100 |
+
last_watered: The date the plant was last watered.
|
| 101 |
+
date: The date to check for.
|
| 102 |
+
LAT: The latitude of the plant's location.
|
| 103 |
+
LON: The longitude of the plant's location.
|
| 104 |
+
"""
|
| 105 |
+
if last_watered is None:
|
| 106 |
+
return True
|
| 107 |
+
|
| 108 |
+
frequency = get_watering_frequency(plant)
|
| 109 |
+
# convert last_watered to datetime.date if it's a string
|
| 110 |
+
if isinstance(last_watered, str):
|
| 111 |
+
last_watered = datetime.datetime.strptime(last_watered, "%Y-%m-%d").date()
|
| 112 |
+
|
| 113 |
+
next_watering_date = last_watered + datetime.timedelta(days=frequency)
|
| 114 |
+
|
| 115 |
+
if date >= next_watering_date and not did_or_will_rain(date, LAT, LON, forecast_threshold=50):
|
| 116 |
+
return True
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
return False
|
modules/weather.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class Weather:
|
| 2 |
+
def __init__(self, temp_max: int = None, temp_min: int = None, precipitation: float = None, precipitation_probability: int = None, wind_speed: float = None, comment: str = None):
|
| 3 |
+
self.temp_max : int = temp_max
|
| 4 |
+
self.temp_min : int = temp_min
|
| 5 |
+
self.precipitation : float = precipitation
|
| 6 |
+
self.precipitation_probability : int = precipitation_probability
|
| 7 |
+
self.wind_speed : float = wind_speed
|
| 8 |
+
self.comment : str = comment
|
modules/weather_test.ipynb
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": null,
|
| 6 |
+
"id": "5726d887",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"name": "stdout",
|
| 11 |
+
"output_type": "stream",
|
| 12 |
+
"text": [
|
| 13 |
+
"Temperature: 22.7 °C\n",
|
| 14 |
+
"Humidity: 54 %\n",
|
| 15 |
+
"Wind speed: 3.6 km/h\n",
|
| 16 |
+
"{'time': '2026-06-08T21:45', 'interval': 900, 'temperature_2m': 22.7, 'relative_humidity_2m': 54, 'wind_speed_10m': 3.6}\n"
|
| 17 |
+
]
|
| 18 |
+
}
|
| 19 |
+
],
|
| 20 |
+
"source": [
|
| 21 |
+
"import requests\n",
|
| 22 |
+
"\n",
|
| 23 |
+
"# Coordinates for Marseille\n",
|
| 24 |
+
"lat = 43.2965\n",
|
| 25 |
+
"lon = 5.3698\n",
|
| 26 |
+
"\n",
|
| 27 |
+
"url = (\n",
|
| 28 |
+
" \"https://api.open-meteo.com/v1/forecast\"\n",
|
| 29 |
+
" f\"?latitude={lat}&longitude={lon}\"\n",
|
| 30 |
+
" \"¤t=temperature_2m,relative_humidity_2m,wind_speed_10m\"\n",
|
| 31 |
+
")\n",
|
| 32 |
+
"\n",
|
| 33 |
+
"data = requests.get(url).json()\n",
|
| 34 |
+
"\n",
|
| 35 |
+
"current = data[\"current\"]\n",
|
| 36 |
+
"\n",
|
| 37 |
+
"print(\"Temperature:\", current[\"temperature_2m\"], \"°C\")\n",
|
| 38 |
+
"print(\"Humidity:\", current[\"relative_humidity_2m\"], \"%\")\n",
|
| 39 |
+
"print(\"Wind speed:\", current[\"wind_speed_10m\"], \"km/h\")\n"
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"cell_type": "code",
|
| 44 |
+
"execution_count": 5,
|
| 45 |
+
"id": "eda4c3a6",
|
| 46 |
+
"metadata": {},
|
| 47 |
+
"outputs": [
|
| 48 |
+
{
|
| 49 |
+
"name": "stdout",
|
| 50 |
+
"output_type": "stream",
|
| 51 |
+
"text": [
|
| 52 |
+
"Chance of rain tomorrow: 35%\n",
|
| 53 |
+
"☀️ Rain unlikely tomorrow\n"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"source": [
|
| 58 |
+
"# check if it rains tommorrow\n",
|
| 59 |
+
"from datetime import datetime, timedelta\n",
|
| 60 |
+
"\n",
|
| 61 |
+
"LATITUDE = 43.2965\n",
|
| 62 |
+
"LONGITUDE = 5.3698\n",
|
| 63 |
+
"\n",
|
| 64 |
+
"url = (\n",
|
| 65 |
+
" \"https://api.open-meteo.com/v1/forecast\"\n",
|
| 66 |
+
" f\"?latitude={LATITUDE}\"\n",
|
| 67 |
+
" f\"&longitude={LONGITUDE}\"\n",
|
| 68 |
+
" \"&daily=precipitation_probability_max\"\n",
|
| 69 |
+
" \"&timezone=auto\"\n",
|
| 70 |
+
")\n",
|
| 71 |
+
"\n",
|
| 72 |
+
"data = requests.get(url, timeout=10).json()\n",
|
| 73 |
+
"\n",
|
| 74 |
+
"tomorrow = (datetime.now() + timedelta(days=1)).strftime(\"%Y-%m-%d\")\n",
|
| 75 |
+
"\n",
|
| 76 |
+
"dates = data[\"daily\"][\"time\"]\n",
|
| 77 |
+
"probs = data[\"daily\"][\"precipitation_probability_max\"]\n",
|
| 78 |
+
"\n",
|
| 79 |
+
"idx = dates.index(tomorrow)\n",
|
| 80 |
+
"prob = probs[idx]\n",
|
| 81 |
+
"\n",
|
| 82 |
+
"print(f\"Chance of rain tomorrow: {prob}%\")\n",
|
| 83 |
+
"\n",
|
| 84 |
+
"if prob >= 50:\n",
|
| 85 |
+
" print(\"🌧️ Likely to rain tomorrow\")\n",
|
| 86 |
+
"else:\n",
|
| 87 |
+
" print(\"☀️ Rain unlikely tomorrow\")"
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"cell_type": "code",
|
| 92 |
+
"execution_count": 8,
|
| 93 |
+
"id": "c806be1c",
|
| 94 |
+
"metadata": {},
|
| 95 |
+
"outputs": [
|
| 96 |
+
{
|
| 97 |
+
"name": "stdout",
|
| 98 |
+
"output_type": "stream",
|
| 99 |
+
"text": [
|
| 100 |
+
"False\n",
|
| 101 |
+
"True\n",
|
| 102 |
+
"False\n"
|
| 103 |
+
]
|
| 104 |
+
}
|
| 105 |
+
],
|
| 106 |
+
"source": [
|
| 107 |
+
"from datetime import date, datetime\n",
|
| 108 |
+
"def did_or_will_rain(\n",
|
| 109 |
+
" target_date,\n",
|
| 110 |
+
" latitude,\n",
|
| 111 |
+
" longitude,\n",
|
| 112 |
+
" forecast_threshold=50,\n",
|
| 113 |
+
"):\n",
|
| 114 |
+
" \"\"\"\n",
|
| 115 |
+
" Returns True if:\n",
|
| 116 |
+
" - it rained on a past date\n",
|
| 117 |
+
" - rain is forecast on a future date above the threshold\n",
|
| 118 |
+
"\n",
|
| 119 |
+
" Parameters\n",
|
| 120 |
+
" ----------\n",
|
| 121 |
+
" target_date : str | date | datetime\n",
|
| 122 |
+
" Date to check (\"YYYY-MM-DD\" or date object)\n",
|
| 123 |
+
" latitude : float\n",
|
| 124 |
+
" longitude : float\n",
|
| 125 |
+
" forecast_threshold : int\n",
|
| 126 |
+
" Minimum rain probability (%) for future dates\n",
|
| 127 |
+
"\n",
|
| 128 |
+
" Returns\n",
|
| 129 |
+
" -------\n",
|
| 130 |
+
" bool\n",
|
| 131 |
+
" \"\"\"\n",
|
| 132 |
+
"\n",
|
| 133 |
+
" # Normalize date\n",
|
| 134 |
+
" if isinstance(target_date, str):\n",
|
| 135 |
+
" target_date = datetime.strptime(target_date, \"%Y-%m-%d\").date()\n",
|
| 136 |
+
" elif isinstance(target_date, datetime):\n",
|
| 137 |
+
" target_date = target_date.date()\n",
|
| 138 |
+
"\n",
|
| 139 |
+
" today = date.today()\n",
|
| 140 |
+
"\n",
|
| 141 |
+
" # Historical date\n",
|
| 142 |
+
" if target_date < today:\n",
|
| 143 |
+
" url = (\n",
|
| 144 |
+
" \"https://archive-api.open-meteo.com/v1/archive\"\n",
|
| 145 |
+
" f\"?latitude={latitude}\"\n",
|
| 146 |
+
" f\"&longitude={longitude}\"\n",
|
| 147 |
+
" f\"&start_date={target_date}\"\n",
|
| 148 |
+
" f\"&end_date={target_date}\"\n",
|
| 149 |
+
" \"&daily=precipitation_sum\"\n",
|
| 150 |
+
" \"&timezone=auto\"\n",
|
| 151 |
+
" )\n",
|
| 152 |
+
"\n",
|
| 153 |
+
" data = requests.get(url, timeout=10).json()\n",
|
| 154 |
+
"\n",
|
| 155 |
+
" rain_mm = data[\"daily\"][\"precipitation_sum\"][0]\n",
|
| 156 |
+
" return rain_mm > 0\n",
|
| 157 |
+
"\n",
|
| 158 |
+
" # Today / future date\n",
|
| 159 |
+
" else:\n",
|
| 160 |
+
" url = (\n",
|
| 161 |
+
" \"https://api.open-meteo.com/v1/forecast\"\n",
|
| 162 |
+
" f\"?latitude={latitude}\"\n",
|
| 163 |
+
" f\"&longitude={longitude}\"\n",
|
| 164 |
+
" \"&daily=precipitation_probability_max,precipitation_sum\"\n",
|
| 165 |
+
" \"&forecast_days=16\"\n",
|
| 166 |
+
" \"&timezone=auto\"\n",
|
| 167 |
+
" )\n",
|
| 168 |
+
"\n",
|
| 169 |
+
" data = requests.get(url, timeout=10).json()\n",
|
| 170 |
+
"\n",
|
| 171 |
+
" dates = data[\"daily\"][\"time\"]\n",
|
| 172 |
+
"\n",
|
| 173 |
+
" target_date_str = target_date.isoformat()\n",
|
| 174 |
+
"\n",
|
| 175 |
+
" if target_date_str not in dates:\n",
|
| 176 |
+
" raise ValueError(\"Date outside forecast range\")\n",
|
| 177 |
+
"\n",
|
| 178 |
+
" idx = dates.index(target_date_str)\n",
|
| 179 |
+
"\n",
|
| 180 |
+
" probability = data[\"daily\"][\"precipitation_probability_max\"][idx]\n",
|
| 181 |
+
" precipitation = data[\"daily\"][\"precipitation_sum\"][idx]\n",
|
| 182 |
+
"\n",
|
| 183 |
+
" return (\n",
|
| 184 |
+
" probability >= forecast_threshold\n",
|
| 185 |
+
" or precipitation > 0\n",
|
| 186 |
+
" )\n",
|
| 187 |
+
" \n",
|
| 188 |
+
"# Marseille\n",
|
| 189 |
+
"LAT = 43.2965\n",
|
| 190 |
+
"LON = 5.3698\n",
|
| 191 |
+
"\n",
|
| 192 |
+
"# Did it rain yesterday?\n",
|
| 193 |
+
"print(did_or_will_rain(\"2026-06-03\", LAT, LON))\n",
|
| 194 |
+
"\n",
|
| 195 |
+
"# Will it rain tomorrow?\n",
|
| 196 |
+
"print(did_or_will_rain(\"2026-06-04\", LAT, LON))\n",
|
| 197 |
+
"\n",
|
| 198 |
+
"# Require at least 70% confidence\n",
|
| 199 |
+
"print(\n",
|
| 200 |
+
" did_or_will_rain(\n",
|
| 201 |
+
" \"2026-06-05\",\n",
|
| 202 |
+
" LAT,\n",
|
| 203 |
+
" LON,\n",
|
| 204 |
+
" forecast_threshold=70,\n",
|
| 205 |
+
" ))"
|
| 206 |
+
]
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"cell_type": "code",
|
| 210 |
+
"execution_count": null,
|
| 211 |
+
"id": "34b8844f",
|
| 212 |
+
"metadata": {},
|
| 213 |
+
"outputs": [],
|
| 214 |
+
"source": [
|
| 215 |
+
"from plant import Plant\n",
|
| 216 |
+
"p = Plant(\"Ficus\")\n",
|
| 217 |
+
"print(p)"
|
| 218 |
+
]
|
| 219 |
+
}
|
| 220 |
+
],
|
| 221 |
+
"metadata": {
|
| 222 |
+
"kernelspec": {
|
| 223 |
+
"display_name": "Python 3",
|
| 224 |
+
"language": "python",
|
| 225 |
+
"name": "python3"
|
| 226 |
+
},
|
| 227 |
+
"language_info": {
|
| 228 |
+
"codemirror_mode": {
|
| 229 |
+
"name": "ipython",
|
| 230 |
+
"version": 3
|
| 231 |
+
},
|
| 232 |
+
"file_extension": ".py",
|
| 233 |
+
"mimetype": "text/x-python",
|
| 234 |
+
"name": "python",
|
| 235 |
+
"nbconvert_exporter": "python",
|
| 236 |
+
"pygments_lexer": "ipython3",
|
| 237 |
+
"version": "3.14.5"
|
| 238 |
+
}
|
| 239 |
+
},
|
| 240 |
+
"nbformat": 4,
|
| 241 |
+
"nbformat_minor": 5
|
| 242 |
+
}
|
modules/weather_utils.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from datetime import date, datetime, timedelta
|
| 3 |
+
from modules.weather import Weather
|
| 4 |
+
def fetch_json(url: str, timeout: int = 10) -> dict:
|
| 5 |
+
resp = requests.get(url, timeout=timeout)
|
| 6 |
+
resp.raise_for_status()
|
| 7 |
+
data = resp.json()
|
| 8 |
+
if data.get("error"):
|
| 9 |
+
raise RuntimeError(f"Open-Meteo API error: {data.get('reason', data)}")
|
| 10 |
+
if "daily" not in data:
|
| 11 |
+
raise RuntimeError(f"Unexpected Open-Meteo response: {data}")
|
| 12 |
+
return data
|
| 13 |
+
def did_or_will_rain(
|
| 14 |
+
target_date,
|
| 15 |
+
latitude,
|
| 16 |
+
longitude,
|
| 17 |
+
forecast_threshold=50,
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Returns True if:
|
| 21 |
+
- it rained on a past date
|
| 22 |
+
- rain is forecast on a future date above the threshold
|
| 23 |
+
|
| 24 |
+
Parameters
|
| 25 |
+
----------
|
| 26 |
+
target_date : str | date | datetime
|
| 27 |
+
Date to check ("YYYY-MM-DD" or date object)
|
| 28 |
+
latitude : float
|
| 29 |
+
longitude : float
|
| 30 |
+
forecast_threshold : int
|
| 31 |
+
Minimum rain probability (%) for future dates
|
| 32 |
+
|
| 33 |
+
Returns
|
| 34 |
+
-------
|
| 35 |
+
bool
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
# Normalize date
|
| 39 |
+
if isinstance(target_date, str):
|
| 40 |
+
target_date = datetime.strptime(target_date, "%Y-%m-%d").date()
|
| 41 |
+
elif isinstance(target_date, datetime):
|
| 42 |
+
target_date = target_date.date()
|
| 43 |
+
|
| 44 |
+
today = date.today()
|
| 45 |
+
|
| 46 |
+
# Historical date
|
| 47 |
+
if target_date < today:
|
| 48 |
+
url = (
|
| 49 |
+
"https://archive-api.open-meteo.com/v1/archive"
|
| 50 |
+
f"?latitude={latitude}"
|
| 51 |
+
f"&longitude={longitude}"
|
| 52 |
+
f"&start_date={target_date}"
|
| 53 |
+
f"&end_date={target_date}"
|
| 54 |
+
"&daily=precipitation_sum"
|
| 55 |
+
"&timezone=auto"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
data = fetch_json(url)
|
| 59 |
+
|
| 60 |
+
rain_mm = data["daily"]["precipitation_sum"][0]
|
| 61 |
+
return rain_mm > 0
|
| 62 |
+
|
| 63 |
+
# Today / future date
|
| 64 |
+
else:
|
| 65 |
+
url = (
|
| 66 |
+
"https://api.open-meteo.com/v1/forecast"
|
| 67 |
+
f"?latitude={latitude}"
|
| 68 |
+
f"&longitude={longitude}"
|
| 69 |
+
"&daily=precipitation_probability_max,precipitation_sum"
|
| 70 |
+
"&forecast_days=16"
|
| 71 |
+
"&timezone=auto"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
data = fetch_json(url)
|
| 75 |
+
|
| 76 |
+
dates = data["daily"]["time"]
|
| 77 |
+
|
| 78 |
+
target_date_str = target_date.isoformat()
|
| 79 |
+
|
| 80 |
+
if target_date_str not in dates:
|
| 81 |
+
raise ValueError("Date outside forecast range")
|
| 82 |
+
|
| 83 |
+
idx = dates.index(target_date_str)
|
| 84 |
+
|
| 85 |
+
probability = data["daily"]["precipitation_probability_max"][idx]
|
| 86 |
+
precipitation = data["daily"]["precipitation_sum"][idx]
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
probability >= forecast_threshold
|
| 90 |
+
or precipitation > 0
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def weather_comment(code: int) -> str:
|
| 95 |
+
WMO_CODES = {
|
| 96 |
+
0: "☀️ Sunny",
|
| 97 |
+
1: "🌤️ Mainly clear",
|
| 98 |
+
2: "⛅ Partly cloudy",
|
| 99 |
+
3: "☁️ Cloudy",
|
| 100 |
+
45: "🌫️ Foggy",
|
| 101 |
+
48: "🌫️ Icy fog",
|
| 102 |
+
51: "🌦️ Light drizzle",
|
| 103 |
+
53: "🌦️ Moderate drizzle",
|
| 104 |
+
55: "🌧️ Dense drizzle",
|
| 105 |
+
56: "🌨️ Light freezing drizzle",
|
| 106 |
+
57: "🌨️ Heavy freezing drizzle",
|
| 107 |
+
61: "🌧️ Slight rain",
|
| 108 |
+
63: "🌧️ Moderate rain",
|
| 109 |
+
65: "🌧️ Heavy rain",
|
| 110 |
+
66: "🌨️ Light freezing rain",
|
| 111 |
+
67: "🌨️ Heavy freezing rain",
|
| 112 |
+
71: "❄️ Slight snowfall",
|
| 113 |
+
73: "❄️ Moderate snowfall",
|
| 114 |
+
75: "❄️ Heavy snowfall",
|
| 115 |
+
77: "🌨️ Snow grains",
|
| 116 |
+
80: "🌦️ Slight rain showers",
|
| 117 |
+
81: "🌧️ Moderate rain showers",
|
| 118 |
+
82: "⛈️ Violent rain showers",
|
| 119 |
+
85: "🌨️ Slight snow showers",
|
| 120 |
+
86: "🌨️ Heavy snow showers",
|
| 121 |
+
95: "⛈️ Thunderstorm",
|
| 122 |
+
96: "⛈️ Thunderstorm with slight hail",
|
| 123 |
+
99: "⛈️ Thunderstorm with heavy hail",
|
| 124 |
+
}
|
| 125 |
+
return WMO_CODES.get(code, "Unknown weather")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def weather_values(date, latitude, longitude):
|
| 129 |
+
# return temperature, wind, precipitation, and a comment (cloudy/sunny/rainy/windy)
|
| 130 |
+
url = (
|
| 131 |
+
"https://api.open-meteo.com/v1/forecast"
|
| 132 |
+
f"?latitude={latitude}"
|
| 133 |
+
f"&longitude={longitude}"
|
| 134 |
+
"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,windspeed_10m_max,weathercode"
|
| 135 |
+
"&forecast_days=16"
|
| 136 |
+
"&timezone=auto"
|
| 137 |
+
)
|
| 138 |
+
data = fetch_json(url)
|
| 139 |
+
dates = data["daily"]["time"]
|
| 140 |
+
# date_str = date.isoformat() if isinstance(date, date) else date
|
| 141 |
+
date_str = date.isoformat()
|
| 142 |
+
if date_str not in dates:
|
| 143 |
+
raise ValueError("Date outside forecast range")
|
| 144 |
+
idx = dates.index(date_str)
|
| 145 |
+
temp_max = data["daily"]["temperature_2m_max"][idx]
|
| 146 |
+
temp_min = data["daily"]["temperature_2m_min"][idx]
|
| 147 |
+
precipitation = data["daily"]["precipitation_sum"][idx]
|
| 148 |
+
precipitation_probability = data["daily"]["precipitation_probability_max"][idx]
|
| 149 |
+
windspeed = data["daily"]["windspeed_10m_max"][idx]
|
| 150 |
+
weathercode = data["daily"]["weathercode"][idx]
|
| 151 |
+
|
| 152 |
+
comment = weather_comment(weathercode)
|
| 153 |
+
return Weather(
|
| 154 |
+
temp_max=temp_max,
|
| 155 |
+
temp_min=temp_min,
|
| 156 |
+
precipitation=precipitation,
|
| 157 |
+
precipitation_probability=precipitation_probability,
|
| 158 |
+
wind_speed=windspeed,
|
| 159 |
+
comment=comment,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
def last_rained_date(latitude, longitude, days_back=15):
|
| 163 |
+
url = (
|
| 164 |
+
"https://archive-api.open-meteo.com/v1/archive"
|
| 165 |
+
f"?latitude={latitude}"
|
| 166 |
+
f"&longitude={longitude}"
|
| 167 |
+
f"&start_date={(date.today() - timedelta(days=days_back + 1 )).isoformat()}"
|
| 168 |
+
f"&end_date={(date.today() - timedelta(days=1)).isoformat()}" # YESTERDAY
|
| 169 |
+
"&daily=precipitation_sum"
|
| 170 |
+
"&timezone=auto"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
data = fetch_json(url)
|
| 174 |
+
dates = data["daily"]["time"]
|
| 175 |
+
precipitation = data["daily"]["precipitation_sum"]
|
| 176 |
+
|
| 177 |
+
for d, p in zip(reversed(dates), reversed(precipitation)):
|
| 178 |
+
if p > 5: # Consider it rained if precipitation > 5mm
|
| 179 |
+
return date.fromisoformat(d)
|
| 180 |
+
|
| 181 |
+
return None # No rain in the past `days_back` days
|
| 182 |
+
|
| 183 |
+
# Marseille
|
| 184 |
+
LAT = 43.2965
|
| 185 |
+
LON = 5.3698
|
| 186 |
+
|
| 187 |
+
# Did it rain yesterday?
|
| 188 |
+
print(did_or_will_rain("2026-06-03", LAT, LON))
|
| 189 |
+
|
| 190 |
+
# Will it rain tomorrow?
|
| 191 |
+
print(did_or_will_rain("2026-06-04", LAT, LON))
|
| 192 |
+
|
| 193 |
+
# Require at least 70% confidence
|
| 194 |
+
print(
|
| 195 |
+
did_or_will_rain(
|
| 196 |
+
"2026-06-05",
|
| 197 |
+
LAT,
|
| 198 |
+
LON,
|
| 199 |
+
forecast_threshold=70,
|
| 200 |
+
))
|