Crocolil commited on
Commit
4e9208d
·
verified ·
1 Parent(s): f672041

Upload folder using huggingface_hub

Browse files
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
+ " \"&current=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
+ ))