Xây dựng kế hoạch tám tuần cho chó con theo phương pháp củng cố tích cực dưới định dạng YAML: lịch trình hàng ngày, huấn luyện đi vệ sinh đúng chỗ, làm quen chuồng và ngủ đêm, các khẩu lệnh theo tuần, bài tập làm quen tiếp xúc và xã hội hóa an toàn, xử lý tật cắn nhấp, tín hiệu căng thẳng, kế hoạch khen thưởng, quy tắc trong gia đình, bảng theo dõi tiến độ, và thời điểm cần tìm chuyên gia.
1role: Người lập kế hoạch huấn luyện và xã hội hóa chó con thân thiện, dựa trên bằng chứng khoa học2task: >3 Xây dựng một kế hoạch huấn luyện và xã hội hóa thực tế trong tám tuần cho một chú chó con mới đón về dưới định dạng YAML.4 Kế hoạch chỉ sử dụng phương pháp củng cố tích cực (positive reinforcement), phù hợp với lịch sinh hoạt hàng ngày của gia đình, và5 bao gồm huấn luyện đi vệ sinh đúng chỗ, làm quen với chuồng hoặc khu vực nghỉ ngơi, làm quen với việc chạm vào cơ thể, các khẩu lệnh cơ bản, kiểm soát lực cắn,6 tập ở một mình, và tiếp xúc bình tĩnh, từ từ với các hình ảnh, âm thanh, con người và địa điểm thường ngày.78inputs:9 puppy_name: ${puppy_name:Biscuit}10 breed_or_mix: ${breed:Labrador mix}...+70 dòng nữa
Xây dựng lịch kiểm tra tưới cây trong nhà từ danh sách các loại cây cùng kích thước và chất liệu chậu, điều kiện ánh sáng, khả năng thoát nước và ngày tưới gần nhất. Điều chỉnh từng khoảng thời gian chờ theo loại chậu, ánh sáng, độ ẩm và mùa (theo bán cầu, giãn cách dài hơn vào mùa đông), sắp xếp lịch kiểm tra vào các ngày cố định trong tuần đã chọn, giải thích rõ thế nào là 'cần tưới nước' và lượng nước cần tưới bao nhiêu, đồng thời cảnh báo các rủi ro như chậu không có lỗ thoát nước. Bao gồm bộ lập lịch bằng Python sử dụng thư viện chuẩn (stdlib) có tính năng xuất tệp .ics và --self-test.
---
name: houseplant-watering-schedule-planner
description: Xây dựng lịch kiểm tra tưới cây trong nhà từ danh sách các loại cây cùng kích thước và chất liệu chậu, điều kiện ánh sáng, khả năng thoát nước và ngày tưới gần nhất. Điều chỉnh từng khoảng thời gian chờ theo loại chậu, ánh sáng, độ ẩm và mùa (theo bán cầu, giãn cách dài hơn vào mùa đông), sắp xếp lịch kiểm tra vào các ngày cố định trong tuần đã chọn, giải thích rõ thế nào là 'cần tưới nước' và lượng nước cần tưới bao nhiêu, đồng thời cảnh báo các rủi ro như chậu không có lỗ thoát nước. Bao gồm bộ lập lịch bằng Python sử dụng thư viện chuẩn (stdlib) có tính năng xuất tệp .ics và --self-test.
---
# Công Cụ Lập Lịch Tưới Cây Trong Nhà
Bạn giúp mọi người giữ cho cây trồng trong nhà luôn tươi tốt mà không chỉ tưới máy móc theo lịch. Kế hoạch sẽ cho họ biết khi nào cần **kiểm tra** từng cây và cần quan sát dấu hiệu gì; họ chỉ tưới nước khi quy tắc riêng của cây đó báo đã đến lúc. Bạn điều chỉnh linh hoạt theo điều kiện thực tế (chậu cây, ánh sáng, hệ thống sưởi, mùa trong năm), giữ cho thói quen thật đơn giản (hai ngày kiểm tra cố định mỗi tuần nếu họ muốn), và chỉ ra những cách bố trí gây chết cây nhiều nhất: chậu không có lỗ thoát nước, cây mọng nước đặt ở góc tối, cây dương xỉ bị khô kiệt trong chậu đất nung (terracotta).
## Các tệp trong kỹ năng này
- `scripts/watering_planner.py` - đọc tệp CSV danh sách cây, gán cho mỗi cây một nhóm nhu cầu nước, tính toán khoảng thời gian giữa các lần kiểm tra theo mùa, tạo lịch trình (có tùy chọn khớp vào các ngày cố định trong tuần), ước tính lượng nước cho mỗi cây, gắn cờ cảnh báo các cách trồng rủi ro, và có thể xuất ra tệp .ics (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/plant-water-profiles.md` - sáu nhóm nhu cầu tưới nước, các loại cây phổ biến trong từng nhóm, cách kiểm tra "cần tưới nước" cũng như dấu hiệu tưới quá nhiều hoặc quá ít nước
- `references/adjustment-factors.md` - cách kích thước chậu, chất liệu chậu, ánh sáng, độ ẩm, mùa và khả năng thoát nước làm thay đổi khoảng cách giữa các lần kiểm tra, kèm theo các hệ số nhân mà kịch bản sử dụng và cách tinh chỉnh chúng sau vài tuần
- `templates/watering-plan.md` - bản kế hoạch dài một trang để gửi lại cho người dùng (lịch trình hàng tuần, bảng tóm tắt nhanh cho từng cây, các cảnh báo, ngày đánh giá lại)
- `examples/plants.csv` - mười chậu cây trong một căn hộ có bật sưởi vào thời điểm cuối tháng Mười
- `examples/plan-output.md` - kết quả đầu ra thực tế của bộ lập lịch cho danh sách cây đó
## Quy trình thực hiện
### 1. Thu thập danh sách cây trồng
Đối với mỗi cây: tên gọi, loại cây (tên loài hoặc mô tả kiểu "trông giống cây mọng nước"), đường kính chậu tính bằng cm, chất liệu chậu (nhựa, gốm tráng men, đất nung terracotta, vải trồng cây, chậu tự tưới), mức độ ánh sáng tại vị trí đặt cây (thấp = xa cửa sổ, trung bình, sáng = ánh sáng gián tiếp mạnh, trực tiếp = nắng chiếu thẳng lên lá hàng giờ liền), chậu có lỗ thoát nước hay không, và lần tưới nước gần nhất là khi nào. Hãy hỏi thêm về bán cầu nơi họ sống, căn nhà có đang bật sưởi hay không (không khí khô), và những ngày nào trong tuần thuận tiện cho việc kiểm tra nhanh. Nếu không rõ tên cây, hãy yêu cầu mô tả qua ảnh hoặc xếp tạm vào nhóm "top_dry" (khô lớp đất mặt) và nói rõ giả định đó.
### 2. Chạy bộ lập lịch
```
python3 scripts/watering_planner.py examples/plants.csv --start 2026-10-30 --weeks 6 --check-days Mon,Thu --humidity low
python3 scripts/watering_planner.py plants.csv --hemisphere south --weeks 8 --ics plants.ics
python3 scripts/watering_planner.py plants.csv --json
python3 scripts/watering_planner.py --self-test
```
Mã thoát 0 = đã tạo xong kế hoạch, 1 = đã tạo kế hoạch nhưng có cảnh báo mức HIGH cần xử lý ngay (ví dụ: không có lỗ thoát nước), 2 = dữ liệu đầu vào không hợp lệ (chất liệu hoặc mức ánh sáng không xác định, kích thước chậu tính bằng mm, định dạng ngày sai). Nếu không chạy mã, hãy tự tra bảng phân loại và tính theo các hệ số nhân trong tài liệu tham chiếu.
### 3. Kiểm tra kết quả trước khi bàn giao
- Mỗi cây đều có một khoảng cách kiểm tra ở thời điểm hiện tại và, nếu kế hoạch kéo dài sang mùa tiếp theo, khoảng cách mới tương ứng (ví dụ: "7 d (winter: 9 d)").
- Những loại cây ưa ẩm liên tục (dương xỉ, đuôi công calathea, lan ý, húng quế) có thể cần nhiều ngày kiểm tra hơn số ngày cố định trong lịch trình; bộ lập lịch sẽ chỉ rõ điều này. Hãy gợi ý thêm ngày kiểm tra thứ ba, đổi sang chậu nhựa, hoặc dùng chậu tự tưới riêng cho những cây này.
- "Overdue" (Quá hạn) có nghĩa là thời điểm tưới lần cuối cộng với khoảng cách kiểm tra đã trôi qua: hãy kiểm tra cây đó đầu tiên.
- Đọc kỹ các cảnh báo và đưa những cảnh báo mức HIGH thành hành động ưu tiên hàng đầu trong kế hoạch.
### 4. Bàn giao kết quả
Điền vào `templates/watering-plan.md`: lịch trình hàng tuần (kiểm tra cây nào vào ngày nào), bảng hướng dẫn nhanh cho từng cây kèm bài kiểm tra "tưới khi nào" và lượng nước cần tưới, các cảnh báo cùng hướng xử lý, và ngày xem xét lại sau 2-3 tuần để giãn ra hoặc rút ngắn khoảng cách kiểm tra dựa trên tình trạng thực tế họ ghi nhận được ở mỗi lần kiểm tra.
## Ví dụ kết quả
Mười chậu cây, bắt đầu từ Thứ Sáu ngày 2026-10-30, kéo dài sáu tuần, kiểm tra vào các ngày Thứ Hai và Thứ Năm, có bật lò sưởi (độ ẩm thấp). Dương xỉ, đuôi công calathea, lan ý và húng quế được kiểm tra vào mọi ngày kiểm tra trong tuần; trầu bà lá xẻ (monstera) khoảng mỗi tuần một lần (các ngày Thứ Năm); trầu bà (pothos) mỗi tuần một lần (các ngày Thứ Hai); lưỡi hổ mỗi 16 ngày, giãn ra thành 21 ngày vào tháng Mười Hai; cây kim tiền (ZZ plant) mỗi 20 ngày; xương rồng hiện tại mỗi 6 ngày, sang mùa đông là 9 ngày (cây này đang quá hạn, lần tưới cuối là 15 tháng Mười). Cây lan ý đang đặt trong chậu trang trí không có lỗ thoát nước (Cảnh báo HIGH: hãy trồng trong chậu nhựa ươm cây đặt lọt bên trong và đổ hết nước đọng ở chậu bọc ngoài sau khi tưới). Cây dương xỉ trồng trong chậu đất nung và cây húng quế cần được kiểm tra mỗi 1-2 ngày, nên lịch kiểm tra hai lần một tuần không đáp ứng đủ. Xem chi tiết tại `examples/plan-output.md`.
## Quy tắc
- Không bao giờ chỉ định tưới nước cứng nhắc theo lịch cố định mà không kiểm tra trước; luôn gắn mỗi lần kiểm tra với bài thử "tưới khi nào" của từng loại cây.
- Tưới đẫm cho đến khi nước chảy ra ở đáy chậu, sau đó đổ bỏ nước thừa trong đĩa hứng sau 15 phút; tuyệt đối không để đáy chậu ngâm trong nước (với hoa lan: ngâm chậu rồi nhấc ra để ráo nước hoàn toàn).
- Vào mùa đông và ở nơi ánh sáng yếu, cây tiêu thụ ít nước hơn: hãy kéo dài khoảng cách giữa các lần tưới thay vì tưới nhấp nháp từng chút một.
- Khi thấy lá vàng đi kèm đất vẫn còn ướt, hãy xác định nguyên nhân là do tưới quá nhiều nước chứ không phải cây bị khát; phải nêu rõ điều này trước khi đề xuất tưới thêm nước.
- Không tự ý đoán loài cây từ một tên gọi mơ hồ; hãy hỏi lại người dùng hoặc nêu rõ nhóm nhu cầu nước mà bạn đã giả định.
- Chỉ đề cập đến vấn đề độc tính đối với thú cưng khi người dùng hỏi về độ an toàn hoặc có nhắc tới thú cưng/trẻ nhỏ; không thêm thắt thông tin lan man vào kế hoạch.
FILE:references/plant-water-profiles.md
# Các nhóm nhu cầu tưới nước
| Nhóm | Các loại cây điển hình | Tưới khi | Khoảng cách kiểm tra cơ sở* | Độ giãn cách vào mùa đông |
|---|---|---|---|---|
| succulent | xương rồng, nha đam (lô hội), sen đá (echeveria), cây ngọc bích (jade), móng rồng (haworthia), chuỗi ngọc (string of pearls) | đất khô hoàn toàn từ trên xuống đáy: cắm một chiếc đũa gỗ chạm đáy chậu khi rút ra thấy sạch và khô ráo; nhấc chậu thấy nhẹ hẳn | 10 ngày | x2.5 (nhiều loại xương rồng hầu như không cần tưới từ tháng 12 đến tháng 2) |
| dry_tolerant | lưỡi hổ (sansevieria), kim tiền (ZZ plant), cọ đuôi ngựa (ponytail palm), yucca, lan cẩm cù (hoya), peperomia | ít nhất 3/4 độ sâu của đất trong chậu đã khô | 12 ngày | x2.0 |
| top_dry | trầu bà (pothos), trầu bà lá xẻ (monstera), philodendron, đa búp đỏ (rubber plant), bàng Singapore (fiddle leaf fig), thiết mộc lan (dracaena), dây nhện (spider plant), kim ngân (money tree), vạn lộc/ngọc ngân (aglaonema), môn quan âm (alocasia), hồng môn (anthurium), cau tiểu trâm (parlor palm), thu hải đường (begonia), xương rồng Giáng sinh (Christmas cactus) | khô 1/3 lớp đất phía trên (thử bằng ngón tay ngập đến đốt thứ hai đối với chậu 15 cm) | 6 ngày | x1.6 |
| evenly_moist | dương xỉ Boston và dương xỉ tóc thần vệ nữ (maidenhair fern), đuôi công (calathea), maranta (prayer plant), lan ý (peace lily), lá may mắn (fittonia) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để đất khô hoàn toàn | 3 ngày | x1.3 |
| orchid | lan hồ điệp (phalaenopsis) trồng trong giá thể vỏ thông | rễ chuyển màu xám bạc (màu xanh lá = vẫn còn ướt) và vỏ thông đã khô; ngâm nước 10 phút, sau đó để ráo hoàn toàn | 7 ngày | x1.4 |
| herb | húng quế, bạc hà, mùi tây (parsley) đặt trên bệ cửa sổ | lớp đất mặt 1-2 cm đã khô | 2 ngày | x1.2 |
*Mức cơ sở = mùa xuân, chậu nhựa 15 cm có lỗ thoát nước, ánh sáng trung bình, độ ẩm bình thường. Mùa thu nằm ở mức giữa mùa xuân và mùa đông; mùa hè nhân với hệ số x0.85. Xem `adjustment-factors.md`.
## Cách kiểm tra độ ẩm (30 giây cho mỗi cây)
1. **Thử bằng ngón tay:** ấn ngón tay vào đất đến độ sâu theo quy tắc của nhóm cây. Đất khô và tơi rời = khô; mát và dính vào tay = còn ẩm.
2. **Ước lượng độ nặng:** hãy nhấc thử các chậu nhỏ ngay sau khi vừa tưới đẫm một lần để cảm nhận sự khác biệt giữa lúc chậu "no nước" và lúc "cạn nước".
3. **Dùng đũa gỗ/que tre:** đối với các chậu sâu lòng và cây mọng nước, hãy cắm một que xiên gỗ xuống tận đáy chậu trong một phút; nếu rút ra thấy que sẫm màu và ẩm nghĩa là chưa cần tưới.
4. **Bút đo độ ẩm đất** chỉ mang tính tham khảo tương đối; thiết bị này đo độ dẫn điện của muối khoáng lẫn nước nên thường báo sai khi cắm vào giá thể vỏ thông hoặc đất trộn nhiều cát.
## Các dấu hiệu nhận biết
| Tưới quá nhiều nước (phổ biến hơn) | Tưới thiếu nước |
|---|---|
| lá tầng dưới ngả vàng trong khi đất vẫn ướt, gốc thân mềm nhũn hoặc chuyển màu đen, rễ bị thối nhũn, xuất hiện ruồi nấm (fungus gnats), đất có mùi chua ủng | mép lá khô giòn chuyển màu nâu, lá rũ xuống nhưng tươi tỉnh lại chỉ sau vài giờ được tưới nước, đất co lại tách rời khỏi thành chậu, nhấc chậu thấy rất nhẹ |
| Cách khắc phục: ngừng tưới ngay, kiểm tra bộ rễ, thay sang giá thể đất khô mới nếu rễ đã bị thối, kéo dài khoảng cách giữa các lần kiểm tra | Cách khắc phục: tưới đẫm hoàn toàn (ngâm đáy chậu trong nước 20-30 phút nếu đất bị trơ không ngấm nước), rút ngắn khoảng cách kiểm tra lại một chút |
Cây bị rũ lá trong khi đất vẫn **ướt** có nghĩa là bộ rễ đã bị tổn thương: tuyệt đối không tưới thêm nước.
## Kỹ thuật tưới nước
- Tưới từ từ khắp bề mặt đất cho đến khi nước chảy ra ở lỗ đáy chậu; chờ một lát, sau đó đổ bỏ nước đọng trong đĩa hứng sau khoảng 15 phút.
- Dùng nước ở nhiệt độ phòng. Cây đuôi công (calathea) và một số loài dương xỉ dễ bị cháy nâu đầu lá nếu tưới bằng nước cứng hoặc nước đã làm mềm bằng muối; dùng nước mưa hoặc nước lọc sẽ tốt hơn.
- Tưới thẩm thấu ngược từ đáy (ngâm đáy chậu trong khay nước khoảng 20-30 phút) rất hiệu quả với các chậu nhỏ và đất bị khô co rút lại; thỉnh thoảng hãy tưới từ trên xuống để rửa trôi muối khoáng tích tụ.
- Chậu tự tưới: châm thêm nước vào bình chứa khi đã cạn và thỉnh thoảng cứ để bình chứa cạn khô trong một hoặc hai ngày để rễ cây được hô hấp; không dùng loại chậu này cho cây mọng nước.
FILE:references/adjustment-factors.md
# Các hệ số điều chỉnh
Khoảng cách kiểm tra = số ngày cơ sở (theo nhóm cây) x kích thước chậu x chất liệu chậu x ánh sáng x độ ẩm x mùa, được làm tròn và giới hạn trong khoảng 1..45 ngày. Dưới đây là các hệ số nhân được sử dụng trong `scripts/watering_planner.py`.
| Yếu tố | Giá trị | Hệ số nhân | Lý do |
|---|---|---|---|
| Đường kính chậu | dưới 10 cm | 0.6 | ít đất, khô chỉ sau vài ngày |
| | 10-13 cm | 0.8 | |
| | 14-19 cm | 1.0 | mức tham chiếu chuẩn |
| | 20-27 cm | 1.3 | nhiều đất hơn nên giữ nước lâu hơn |
| | từ 28 cm trở lên | 1.6 | chậu lớn vẫn còn ướt ở đáy rất lâu sau khi bề mặt đã khô: cần kiểm tra sâu xuống dưới |
| Chất liệu | nhựa, tráng men, gốm sứ, kim loại | 1.0 | thành chậu không thoát hơi nước |
| | đất nung (terracotta) / đất sét không tráng men | 0.75 | thành chậu thấm hút và làm bay hơi nước |
| | túi vải trồng cây (fabric grow bag) | 0.7 | khô nhanh từ mọi phía |
| | chậu tự tưới | 1.5 | bình chứa cấp ẩm dần cho đất; hãy kiểm tra bình chứa nước thay vì mặt đất |
| Ánh sáng | thấp (cách cửa sổ trên 2 m, phòng hướng Bắc) | 1.4 | cây ít phát triển, tiêu thụ ít nước |
| | trung bình | 1.0 | |
| | ánh sáng gián tiếp mạnh | 0.85 | |
| | nắng chiếu trực tiếp lên lá | 0.7 | |
| Độ ẩm | thấp (đang bật sưởi, dưới ~35 %) | 0.85 | đất và lá mất nước nhanh hơn |
| | bình thường | 1.0 | |
| | cao (phòng tắm, có máy tạo ẩm) | 1.15 | |
| Mùa | mùa xuân | 1.0 | |
| | mùa hè | 0.85 | |
| | mùa thu | mức giữa của mùa xuân và mùa đông | đà tăng trưởng chậm lại khi ngày ngắn đi |
| | mùa đông | hệ số giãn cách mùa đông theo từng nhóm cây (từ x1.2 đến x2.5) | |
Phân chia mùa: ở Bắc bán cầu, tháng 12-tháng 2 là mùa đông, tháng 3-tháng 5 là mùa xuân, tháng 6-tháng 8 là mùa hè, tháng 9-tháng 11 là mùa thu; ở Nam bán cầu lệch đi sáu tháng. Nếu đặt cây ngay cạnh lò sưởi hoặc trong phòng kính có sưởi ấm, hãy chọn mức độ ẩm "low" (thấp) ngay cả vào mùa đông.
## Khả năng thoát nước
Chậu không có lỗ thoát nước là nguyên nhân đơn lẻ lớn nhất gây thối rễ. Cách tốt nhất: giữ nguyên cây trong chậu nhựa ươm cây đặt lọt bên trong chậu trang trí và đổ hết nước đọng ở chậu ngoài sau khi tưới 15 phút. Nếu bắt buộc phải trồng trực tiếp trong chậu không có lỗ, chỉ tưới khoảng một nửa lượng nước thông thường và nghiêng nhẹ chậu để kiểm tra xem có nước đọng dưới đáy không. Bộ lập lịch sẽ gắn cờ trường hợp này ở mức cảnh báo HIGH.
## Lượng nước tưới
Khoảng 15-25 % thể tích chậu, tưới cho đến khi thấy nước thoát ra ở lỗ đáy. Bộ lập lịch ước tính thể tích chậu từ đường kính (giả định chậu thành đứng có chiều cao bằng khoảng 0.9 lần chiều rộng), vì vậy chậu 15 cm cần khoảng 450 ml nước, chậu 24 cm cần khoảng 1.75 lít. Cây mọng nước và cây chịu hạn cũng cần được tưới đẫm mỗi lần như vậy, chỉ là tần suất thưa hơn.
## Tinh chỉnh sau hai hoặc ba tuần
- Đất vẫn còn ẩm ở hầu hết các lần kiểm tra: hãy kéo dài khoảng cách kiểm tra của cây đó thêm 25-50 % (hoặc chuyển thông số ánh sáng sang "low" nếu cây đặt xa cửa sổ).
- Đất khô cong ở mọi lần kiểm tra và lá cây bị rũ xuống: rút ngắn khoảng cách kiểm tra đi 25 %.
- Hãy cập nhật trực tiếp các thay đổi này vào tệp CSV (ví dụ: đổi nhóm cây hoặc mức ánh sáng) rồi chạy lại công cụ, thay vì cố ghi nhớ các trường hợp ngoại lệ trong đầu.
- Chạy lại kế hoạch khi bắt đầu bật hoặc tắt hệ thống sưởi, khi bạn thay chậu mới (chậu lớn hơn = khoảng cách dài hơn), hoặc khi đổi vị trí đặt cây.
## Các trường hợp nằm ngoài quy tắc tính khoảng cách
- Cây mới thay chậu: tưới đẫm một lần ngay sau khi thay chậu, sau đó chờ đến khi đất đạt đúng điều kiện trong quy tắc mới tưới tiếp.
- Các loại củ ngủ đông và xương rồng trong giai đoạn nghỉ đông lạnh giá (5-12 C): hầu như không cần tưới nước; chỉ cần kiểm tra mỗi tháng một lần là đủ.
- Bón phân: chỉ thực hiện vào mùa xuân và mùa hè, pha loãng bằng một nửa liều lượng khuyến cáo, bón vào đúng ngày tưới nước.
FILE:templates/watering-plan.md
# Kế hoạch chăm sóc cây: <nhà / phòng>
**Giai đoạn:** <2026-10-30 đến 2026-12-10> **Ngày kiểm tra:** <Thứ Hai và Thứ Năm, 5-10 phút>
**Điều kiện:** <Bắc bán cầu, đang bật sưởi (không khí khô)> **Đánh giá lại vào:** <2026-11-16>
## Việc cần làm ngay
- <Lan ý (Peace lily): không có lỗ thoát nước - hãy chuyển cây sang chậu nhựa ươm đặt lọt bên trong chậu bọc ngoài; đổ bỏ nước đọng ở chậu bọc ngoài sau khi tưới 15 phút>
- <Spike (xương rồng): đã quá hạn - cần kiểm tra ngay vào Thứ Hai>
## Lịch trình hàng tuần
| Ngày | Kiểm tra các cây sau |
|---|---|
| <Thứ Hai> | <dương xỉ, đuôi công calathea, lan ý, húng quế, hoa lan, trầu bà pothos, xương rồng (mỗi Thứ Hai cách tuần trong tháng Mười Hai)> |
| <Thứ Năm> | <dương xỉ, đuôi công calathea, lan ý, húng quế, hoa lan, trầu bà lá xẻ monstera; lưỡi hổ mỗi 2-3 tuần> |
## Bảng hướng dẫn nhanh
| Cây | Tưới khi | Lượng nước ước tính | Ghi chú |
|---|---|---|---|
| <Monty (monstera)> | <khô 1/3 lớp đất mặt> | <~1.75 l cho đến khi thoát nước> | <cửa sổ hướng Đông nhiều sáng> |
| <Sansa (snake plant)> | <khô 3/4 độ sâu đất> | <~450 ml> | <Tháng Mười Hai: mỗi 3 tuần> |
## Cách kiểm tra
- Ấn ngón tay vào đất đến độ sâu ghi trong cột "Tưới khi"; chỉ tưới nếu đất đã khô đến độ sâu đó.
- Tưới cho đến khi nước chảy ra ở đáy chậu, đổ hết nước trong đĩa hứng sau 15 phút.
- Đất ướt + lá vàng = thừa nước: bỏ qua lượt tưới và báo lại cho tôi.
## Cảnh báo
- <Fernando (dương xỉ Boston) trồng trong chậu đất nung bị khô chỉ sau 2 ngày khi bật sưởi: hãy thêm một buổi kiểm tra vào Thứ Bảy hoặc chuyển sang chậu nhựa>
- <Húng quế cần được kiểm tra hàng ngày trên bệ cửa sổ đầy nắng; hãy tưới ngâm đáy chậu mỗi khi bạn đi ngang qua>
## Khi đánh giá lại
Đối với mỗi cây, hãy ghi chú xem đất thường vẫn còn ẩm (cần giãn cách dài hơn) hay đã khô kiệt (cần rút ngắn lại) vào các buổi kiểm tra; tôi sẽ cập nhật lại kế hoạch.
FILE:examples/plants.csv
name,plant,pot_cm,pot_material,light,drainage,last_watered,notes
Monty,monstera,24,plastic,bright,yes,2026-10-28,living room by the east window
Pothos shelf,pothos,14,glazed,medium,yes,2026-10-27,trailing from the bookshelf
Sansa,snake plant,17,terracotta,low,yes,2026-10-20,hallway corner
Zed,zz plant,20,plastic,medium,yes,,new from the shop
Fernando,boston fern,20,terracotta,bright,yes,2026-10-29,bathroom shelf
Cal,calathea,15,plastic,medium,yes,2026-10-28,bedroom
Spike,cactus,10,terracotta,direct,yes,2026-10-15,south windowsill
Peace lily,peace lily,18,glazed,low,no,2026-10-26,decorative pot with no hole
Orchid,phalaenopsis,12,plastic,bright,yes,2026-10-25,kitchen window
Basil,basil,13,terracotta,direct,yes,2026-10-29,kitchen windowsill
FILE:examples/plan-output.md
# Kế hoạch tưới cây trong nhà: 2026-10-30 đến 2026-12-10
Bán cầu: Bắc Mùa lúc bắt đầu: mùa thu Độ ẩm: thấp Ngày kiểm tra: Thứ Hai, Thứ Năm Số lượng cây: 10
Kiểm tra không đồng nghĩa với tưới nước: hãy chạm tay cảm nhận độ ẩm của đất và chỉ tưới khi quy tắc của cây báo đã đến lúc.
## Danh sách cây
| Cây | Nhóm | Chậu | Ánh sáng | Kiểm tra mỗi | Tưới khi | Lượng nước ước tính | Số lần kiểm tra |
|---|---|---|---|---|---|---|---|
| Monty (monstera) | top_dry | nhựa 24 cm | sáng | 7 d (winter: 9 d) | khô 1/3 lớp đất phía trên (khoảng 3-5 cm đối với chậu 15 cm) | ~1750 ml (cho đến khi thoát nước) | 6 |
| Pothos shelf (pothos) | top_dry | tráng men 14 cm | trung bình | 7 d (winter: 8 d) | khô 1/3 lớp đất phía trên (khoảng 3-5 cm đối với chậu 15 cm) | ~350 ml (cho đến khi thoát nước) | 6 |
| Sansa (snake plant) | dry_tolerant | đất nung 17 cm | thấp | 16 d (winter: 21 d) | đất khô ít nhất 3/4 độ sâu từ trên xuống | ~450 ml (cho đến khi thoát nước) | 3 |
| Zed (zz plant) | dry_tolerant | nhựa 20 cm | trung bình | 20 d | đất khô ít nhất 3/4 độ sâu từ trên xuống | ~750 ml (cho đến khi thoát nước) | 2 |
| Fernando (boston fern) | evenly_moist | đất nung 20 cm | sáng | 2 d (winter: 3 d) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để khô hoàn toàn | ~1000 ml (cho đến khi thoát nước) | 12 |
| Cal (calathea) | evenly_moist | nhựa 15 cm | trung bình | 3 d (winter: 3 d) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để khô hoàn toàn | ~450 ml (cho đến khi thoát nước) | 12 |
| Spike (cactus) (quá hạn: kiểm tra ngay ngày đầu tiên) | succulent | đất nung 10 cm | trực tiếp | 6 d (winter: 9 d) | đất khô hoàn toàn đến tận đáy (rút đũa gỗ ra thấy sạch và khô) | ~100 ml (cho đến khi thoát nước) | 6 |
| Peace lily (peace lily) | evenly_moist | tráng men 18 cm, KHÔNG lỗ | thấp | 4 d (winter: 5 d) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để khô hoàn toàn | ~375 ml, không tưới nhiều hơn | 12 |
| Orchid (phalaenopsis) | orchid | nhựa 12 cm | sáng | 5 d (winter: 6 d) | rễ màu xám bạc và vỏ thông khô; ngâm 10 phút, để ráo hoàn toàn | ngâm 10 phút | 11 |
| Basil (basil) | herb | đất nung 13 cm | trực tiếp | 1 d (winter: 1 d) | lớp đất mặt 1-2 cm đã khô | ~350 ml (cho đến khi thoát nước) | 12 |
## Lịch kiểm tra
| Ngày | Thứ | Mùa | Cây cần kiểm tra |
|---|---|---|---|
| 2026-11-02 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-05 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-09 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-12 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-16 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-19 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-23 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-26 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-30 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-12-03 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-12-07 | Mon | winter | Basil, Cal, Fernando, Peace lily, Pothos shelf, Spike |
| 2026-12-10 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
## Cảnh báo
| Mức độ | Cây | Lưu ý |
|---|---|---|
| HIGH | Peace lily | chậu không có lỗ thoát nước: nước đọng ở đáy làm thối rễ. Hãy thay chậu có lỗ, hoặc trồng cây trong chậu nhựa ươm đặt lọt bên trong chậu bọc ngoài và đổ hết nước đọng sau khi tưới 15 phút |
| LOW | Fernando | chậu đất nung làm cây này khô rất nhanh; đổi sang chậu nhựa hoặc chậu gốm tráng men sẽ giúp giữ ẩm đều hơn |
| LOW | Fernando | các ngày kiểm tra cố định khiến một số lượt kiểm tra bị trễ hơn một ngày (ví dụ: đến hạn 2026-11-07, lịch xếp vào 2026-11-09); hãy thêm một ngày kiểm tra trong tuần hoặc sờ thử đất xen kẽ giữa các buổi |
| LOW | Basil | các ngày kiểm tra cố định khiến một số lượt kiểm tra bị trễ hơn một ngày (ví dụ: đến hạn 2026-11-03, lịch xếp vào 2026-11-05); hãy thêm một ngày kiểm tra trong tuần hoặc sờ thử đất xen kẽ giữa các buổi |
Các khoảng thời gian trên giả định rằng cây đã bén rễ ổn định trong giá thể thoát nước tốt. Sau 2-3 tuần, hãy kéo dài khoảng cách kiểm tra nếu thấy đất vẫn còn ẩm ở hầu hết các lần kiểm tra, hoặc rút ngắn lại nếu lúc nào kiểm tra đất cũng đã khô kiệt.
FILE:scripts/watering_planner.py
#!/usr/bin/env python3
"""Houseplant watering schedule planner (Python 3.8+ standard library only).
Reads a CSV of plants (species or watering type, pot diameter, pot material, light,
drainage, optional last watering date) and builds a check calendar: how often to
check each plant in the current season, what "needs water" means for it, roughly how
much to give, and which plants to check on which date. Intervals stretch
automatically as the plan crosses into autumn and winter (or shrink into summer), by
hemisphere. Checks can be snapped to fixed weekdays (e.g. Mon,Thu) so the plan fits a
routine. Optional .ics export for a calendar app.
A check is not a watering: on each check, feel the soil and water only if the plant's
rule says so. The intervals are starting points; adjust them after two or three weeks
of real checks (see references/adjustment-factors.md).
Usage:
python3 watering_planner.py plants.csv [--start 2026-10-30] [--weeks 4]
[--hemisphere north|south] [--humidity low|normal|high]
[--check-days Mon,Thu] [--ics plan.ics] [--json]
python3 watering_planner.py --self-test
plants.csv columns: name, plant (species or type), pot_cm, pot_material
(plastic|glazed|ceramic|terracotta|fabric|self-watering), light (low|medium|bright|direct),
drainage (yes|no), last_watered (YYYY-MM-DD, optional), notes (optional).
Types: succulent, dry_tolerant, top_dry, evenly_moist, orchid, herb.
Exit codes: 0 = plan written, 1 = plan written with HIGH warnings (e.g. no drainage), 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, math, sys
PROFILES = {
# base days (spring, 15 cm plastic, medium light), winter factor, rule, share of pot volume per watering
"succulent": (10, 2.5, "soil dry all the way down (chopstick comes out clean and dry)", 0.15),
"dry_tolerant": (12, 2.0, "soil dry at least 3/4 of the way down", 0.15),
"top_dry": (6, 1.6, "top third of the soil dry (about 3-5 cm in a 15 cm pot)", 0.20),
"evenly_moist": (3, 1.3, "top 1-2 cm just dry; never let it dry out fully", 0.20),
"orchid": (7, 1.4, "roots silvery-grey and bark dry; soak 10 min, drain fully", 0.0),
"herb": (2, 1.2, "top 1-2 cm dry", 0.25),
}
SPECIES = {
"cactus": "succulent", "aloe": "succulent", "aloe vera": "succulent", "echeveria": "succulent",
"jade": "succulent", "jade plant": "succulent", "haworthia": "succulent", "string of pearls": "succulent",
"christmas cactus": "top_dry", "snake plant": "dry_tolerant", "sansevieria": "dry_tolerant",
"zz plant": "dry_tolerant", "zamioculcas": "dry_tolerant", "ponytail palm": "dry_tolerant",
"hoya": "dry_tolerant", "pothos": "top_dry", "monstera": "top_dry", "philodendron": "top_dry",
"rubber plant": "top_dry", "ficus elastica": "top_dry", "fiddle leaf fig": "top_dry",
"dracaena": "top_dry", "spider plant": "top_dry", "money tree": "top_dry", "pachira": "top_dry",
"chinese evergreen": "top_dry", "aglaonema": "top_dry", "dieffenbachia": "top_dry",
"yucca": "dry_tolerant", "bird of paradise": "top_dry", "alocasia": "top_dry", "anthurium": "top_dry",
"parlor palm": "top_dry", "peperomia": "dry_tolerant", "begonia": "top_dry",
"fern": "evenly_moist", "boston fern": "evenly_moist", "maidenhair fern": "evenly_moist",
"calathea": "evenly_moist", "prayer plant": "evenly_moist", "maranta": "evenly_moist",
"peace lily": "evenly_moist", "spathiphyllum": "evenly_moist", "fittonia": "evenly_moist",
"orchid": "orchid", "phalaenopsis": "orchid", "basil": "herb", "mint": "herb", "parsley": "herb",
}
MATERIAL = {"plastic": 1.0, "glazed": 1.0, "ceramic": 1.0, "metal": 1.0, "terracotta": 0.75,
"clay": 0.75, "unglazed": 0.75, "fabric": 0.7, "self-watering": 1.5}
LIGHT = {"low": 1.4, "medium": 1.0, "bright": 0.85, "direct": 0.7}
HUMIDITY = {"low": 0.85, "normal": 1.0, "high": 1.15}
SEASON_FACTOR = {"spring": 1.0, "summer": 0.85}
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
class InputError(Exception):
pass
def season_of(d, hemisphere):
m = d.month if hemisphere == "north" else (d.month + 5) % 12 + 1
return {12: "winter", 1: "winter", 2: "winter", 3: "spring", 4: "spring", 5: "spring",
6: "summer", 7: "summer", 8: "summer"}.get(m, "autumn")
def pot_factor(cm):
if cm < 10:
return 0.6
if cm < 14:
return 0.8
if cm < 20:
return 1.0
if cm < 28:
return 1.3
return 1.6
def pot_litres(cm):
# straight-sided pot about 0.9x as tall as wide, minus ~10 % for the rim gap
r = cm / 2.0
return math.pi * r * r * (0.9 * cm) * 0.9 / 1000.0
def interval_days(p, season, humidity):
base, winter, _, _ = PROFILES[p["type"]]
# autumn sits halfway between spring and the plant's winter slow-down
sf = {"winter": winter, "autumn": (1 + winter) / 2}.get(season) or SEASON_FACTOR[season]
days = base * pot_factor(p["pot_cm"]) * MATERIAL[p["material"]] * LIGHT[p["light"]] * HUMIDITY[humidity] * sf
return max(1, min(45, int(round(days))))
def parse_date(s, what):
try:
return dt.date.fromisoformat(s.strip())
except ValueError:
raise InputError(f"bad date '{s}' for {what} (use YYYY-MM-DD)")
def load_plants(text):
rows = list(csv.DictReader(io.StringIO(text)))
if not rows:
raise InputError("plants CSV has no rows")
cols = {c.strip().lower(): c for c in rows[0] if c}
if "name" not in cols:
raise InputError("plants CSV needs at least a 'name' column (plus plant, pot_cm, pot_material, light, drainage)")
plants, warnings = [], []
for i, r in enumerate(rows, 2):
g = lambda k, d="": ((r.get(cols[k]) or "").strip() if k in cols else "") or d
name = g("name")
if not name:
raise InputError(f"row {i}: empty name")
kind = g("plant", g("type", "")).lower()
if kind in PROFILES:
ptype = kind
elif kind in SPECIES:
ptype = SPECIES[kind]
else:
ptype = next((v for k, v in SPECIES.items() if k in kind), None) if kind else None
if ptype is None:
ptype = "top_dry"
warnings.append(("MEDIUM", name, f"unknown plant '{kind or '?'}': using type top_dry; set the plant column to one of {', '.join(PROFILES)}"))
try:
pot = float(g("pot_cm", "0") or 0)
except ValueError:
raise InputError(f"row {i} ({name}): pot_cm must be a number of centimetres")
if pot <= 0:
pot = 15.0
warnings.append(("LOW", name, "no pot size given: assumed 15 cm"))
if pot > 80:
raise InputError(f"row {i} ({name}): pot_cm {pot} looks wrong (centimetres, not millimetres?)")
mat = g("pot_material", "plastic").lower()
if mat not in MATERIAL:
raise InputError(f"row {i} ({name}): pot_material '{mat}' (use {', '.join(MATERIAL)})")
light = g("light", "medium").lower().replace("bright indirect", "bright").replace("full sun", "direct")
if light not in LIGHT:
raise InputError(f"row {i} ({name}): light '{light}' (use low, medium, bright, direct)")
drainage = g("drainage", "yes").lower() not in ("no", "n", "false", "0")
last = parse_date(g("last_watered"), f"{name} last_watered") if g("last_watered") else None
p = {"name": name, "plant": kind or ptype, "type": ptype, "pot_cm": pot, "material": mat,
"light": light, "drainage": drainage, "last_watered": last, "notes": g("notes")}
plants.append(p)
if not drainage:
warnings.append(("HIGH", name, "pot has no drainage hole: water pools at the bottom and roots rot. Repot, or keep the plant in a nursery pot inside the cover pot and empty the cover pot 15 min after watering"))
if ptype == "succulent" and light == "low":
warnings.append(("MEDIUM", name, "succulent in low light: it will stretch and rot easily; move it to the brightest window and water even less"))
if ptype == "evenly_moist" and light == "direct":
warnings.append(("MEDIUM", name, "moisture-loving plant in direct sun: leaves scorch and soil dries within a day; move it out of direct sun"))
if ptype == "evenly_moist" and mat in ("terracotta", "clay", "unglazed", "fabric"):
warnings.append(("LOW", name, f"{mat} pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist"))
if ptype == "orchid" and mat not in ("plastic",):
warnings.append(("LOW", name, "orchids are easiest in a clear plastic pot with drainage, so you can see the roots"))
if mat == "self-watering" and ptype in ("succulent", "dry_tolerant", "orchid"):
warnings.append(("MEDIUM", name, "self-watering pots keep the soil wet; not suited to plants that need to dry out"))
return plants, warnings
def amount_text(p):
_, _, _, share = PROFILES[p["type"]]
if p["type"] == "orchid":
return "soak 10 min"
if p["material"] == "self-watering":
return "refill reservoir"
ml = pot_litres(p["pot_cm"]) * 1000 * share
ml = int(round(ml / 50.0) * 50) or 50
return f"~{ml} ml (until it drains)" if p["drainage"] else f"~{ml // 2} ml, no more"
def snap(due, prev, allowed):
"""Allowed weekday nearest to the due date (ties go earlier), always after prev."""
if not allowed:
return due
best = None
for off in (0, -1, 1, -2, 2, -3, 3):
d = due + dt.timedelta(days=off)
if d > prev and d.weekday() in allowed:
best = d
break
if best is None:
best = max(due, prev + dt.timedelta(days=1))
while best.weekday() not in allowed:
best += dt.timedelta(days=1)
return best
def plan(plants, start, weeks, hemisphere="north", humidity="normal", check_days=None):
end = start + dt.timedelta(days=7 * weeks - 1)
allowed = {DAY_NAMES.index(x) for x in check_days} if check_days else set()
cal, per_plant, notes = {}, [], []
for p in plants:
s0 = season_of(start, hemisphere)
iv = interval_days(p, s0, humidity)
if p["last_watered"]:
due = p["last_watered"] + dt.timedelta(days=iv)
overdue = due < start
first = max(due, start)
else:
overdue, first = False, start
d = snap(first, start - dt.timedelta(days=1), allowed) if allowed else first
dates, prev = [], None
while d <= end:
dates.append(d)
cal.setdefault(d, []).append(p["name"])
step = interval_days(p, season_of(d, hemisphere), humidity)
due = d + dt.timedelta(days=step)
nd = snap(due, d, allowed)
if allowed and (nd - due).days > max(1, step // 5):
notes.append((p["name"], due, nd))
prev, d = d, nd
later = {season_of(x, hemisphere) for x in dates} - {s0}
per_plant.append({**p, "interval_now": iv, "season_now": s0,
"intervals_later": {s: interval_days(p, s, humidity) for s in sorted(later)},
"rule": PROFILES[p["type"]][2], "amount": amount_text(p),
"checks": [x.isoformat() for x in dates], "overdue": overdue})
return {"start": start, "end": end, "hemisphere": hemisphere, "humidity": humidity,
"check_days": check_days or [], "plants": per_plant,
"calendar": [(d, sorted(cal[d], key=str.lower)) for d in sorted(cal)], "late_snaps": notes}
def render(res, warnings):
st, en = res["start"], res["end"]
out = [f"# Houseplant watering plan: {st.isoformat()} to {en.isoformat()}", "",
f"Hemisphere: {res['hemisphere']} Season at start: {season_of(st, res['hemisphere'])} "
f"Humidity: {res['humidity']} Check days: {', '.join(res['check_days']) or 'any day'} Plants: {len(res['plants'])}", "",
"A check is not a watering: feel the soil and water only when the rule says so.", "",
"## Plants", "",
"| Plant | Type | Pot | Light | Check every | Water when | About | Checks |",
"|---|---|---|---|---|---|---|---|"]
for p in res["plants"]:
iv = f"{p['interval_now']} d"
if p["intervals_later"]:
iv += " (" + ", ".join(f"{s}: {v} d" for s, v in p["intervals_later"].items()) + ")"
pot = f"{p['pot_cm']:g} cm {p['material']}" + ("" if p["drainage"] else ", NO hole")
flag = " (overdue: check first day)" if p["overdue"] else ""
out.append(f"| {p['name']} ({p['plant']}){flag} | {p['type']} | {pot} | {p['light']} | {iv} | {p['rule']} | {p['amount']} | {len(p['checks'])} |")
out += ["", "## Calendar", "", "| Date | Day | Season | Check |", "|---|---|---|---|"]
for d, names in res["calendar"]:
out.append(f"| {d.isoformat()} | {DAY_NAMES[d.weekday()]} | {season_of(d, res['hemisphere'])} | {', '.join(names)} |")
if warnings or res["late_snaps"]:
out += ["", "## Warnings", "", "| Sev | Plant | Note |", "|---|---|---|"]
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
for sev, name, msg in sorted(warnings, key=lambda w: (order[w[0]], w[1].lower())):
out.append(f"| {sev} | {name} | {msg} |")
seen = set()
for name, due, nd in res["late_snaps"]:
if name in seen:
continue
seen.add(name)
out.append(f"| LOW | {name} | check days push some checks more than a day late (e.g. due {due.isoformat()}, planned {nd.isoformat()}); add a check day or feel the soil in between |")
out += ["", "Intervals assume the plant is established in a free-draining mix. After 2-3 weeks, lengthen an interval if the soil is still moist at most checks, shorten it if it is always bone dry."]
return "\n".join(out) + "\n"
def to_ics(res, stamp):
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//houseplant-watering-schedule-planner//EN", "CALSCALE:GREGORIAN"]
for d, names in res["calendar"]:
nd = d + dt.timedelta(days=1)
lines += ["BEGIN:VEVENT", f"UID:plants-{d.strftime('%Y%m%d')}@watering-planner", f"DTSTAMP:{stamp}",
f"DTSTART;VALUE=DATE:{d.strftime('%Y%m%d')}", f"DTEND;VALUE=DATE:{nd.strftime('%Y%m%d')}",
"SUMMARY:" + ics_escape("Check plants: " + ", ".join(names)), "TRANSP:TRANSPARENT", "END:VEVENT"]
lines.append("END:VCALENDAR")
return "\r\n".join(fold(l) for l in lines) + "\r\n"
def ics_escape(s):
return s.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
def fold(line):
b = line.encode("utf-8")
if len(b) <= 75:
return line
parts, cur = [], b""
for ch in line:
e = ch.encode("utf-8")
if len(cur) + len(e) > (75 if not parts else 74):
parts.append(cur.decode("utf-8")); cur = b""
cur += e
parts.append(cur.decode("utf-8"))
return "\r\n ".join(parts)
def self_test():
H = "name,plant,pot_cm,pot_material,light,drainage,last_watered\n"
def run(rows, **kw):
plants, warns = load_plants(H + rows)
return plan(plants, kw.pop("start", dt.date(2026, 4, 6)), kw.pop("weeks", 4), **kw), warns
cases = []
def case(name, fn):
try:
ok = bool(fn())
err = ""
except Exception as e: # noqa
ok, err = False, repr(e)
cases.append((name, ok, err))
case("succulent checked less often than fern", lambda: run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][1]["interval_now"])
case("base interval top_dry 15 cm plastic medium spring = 6", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] == 6)
case("small pot dries faster", lambda: run("A,pothos,9,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
case("big pot lasts longer", lambda: run("A,pothos,30,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > 6)
case("terracotta dries faster than plastic", lambda: run("A,pothos,15,terracotta,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
case("low light lengthens, direct sun shortens", lambda: run("A,pothos,15,plastic,low,yes,\n")[0]["plants"][0]["interval_now"] > 6 > run("A,pothos,15,plastic,direct,yes,\n")[0]["plants"][0]["interval_now"])
case("winter stretches succulent 2.5x", lambda: run("A,cactus,15,plastic,medium,yes,\n", start=dt.date(2026, 1, 5))[0]["plants"][0]["interval_now"] == 25)
case("summer shortens", lambda: run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 7, 6))[0]["plants"][0]["interval_now"] == 5)
case("southern hemisphere: July is winter", lambda: season_of(dt.date(2026, 7, 1), "south") == "winter" and season_of(dt.date(2026, 1, 1), "south") == "summer")
case("season boundaries north", lambda: [season_of(dt.date(2026, m, 15), "north") for m in (2, 3, 5, 6, 8, 9, 11, 12)] == ["winter", "spring", "spring", "summer", "summer", "autumn", "autumn", "winter"])
case("interval grows across autumn->winter in plan", lambda: (lambda r: r["plants"][0]["intervals_later"].get("winter", 0) > r["plants"][0]["interval_now"])(run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 11, 16), weeks=4)[0]))
case("checks spaced by interval", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["checks"][:3] == ["2026-04-06", "2026-04-12", "2026-04-18"])
case("last_watered sets first check", lambda: run("A,pothos,15,plastic,medium,yes,2026-04-04\n")[0]["plants"][0]["checks"][0] == "2026-04-10")
case("overdue goes to start", lambda: (lambda p: p["overdue"] and p["checks"][0] == "2026-04-06")(run("A,pothos,15,plastic,medium,yes,2026-03-20\n")[0]["plants"][0]))
case("snap to check days (Mon,Thu)", lambda: all(dt.date.fromisoformat(x).weekday() in (0, 3) for x in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["calendar"] and [x for p in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["plants"] for x in p["checks"]]))
case("snap to nearest check day (Sun -> Mon)", lambda: snap(dt.date(2026, 4, 12), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 13))
case("snap tie goes earlier (Tue between Mon and Thu? no: Tue -> Mon)", lambda: snap(dt.date(2026, 4, 14), dt.date(2026, 4, 9), {0, 3}) == dt.date(2026, 4, 13))
case("snap never on or before the previous check", lambda: snap(dt.date(2026, 4, 7), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 9))
case("autumn between spring and winter", lambda: 6 < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 10, 5))[0]["plants"][0]["interval_now"] < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 12, 7))[0]["plants"][0]["interval_now"])
case("late snap noted for fast plant", lambda: len(run("A,basil,9,terracotta,direct,yes,\n", check_days=["Mon"])[0]["late_snaps"]) > 0)
case("no drainage -> HIGH warning and half amount", lambda: (lambda r: any(w[0] == "HIGH" for w in r[1]) and "no more" in r[0]["plants"][0]["amount"])(run("A,pothos,15,glazed,medium,no,\n")))
case("unknown plant -> top_dry with warning", lambda: (lambda r: r[0]["plants"][0]["type"] == "top_dry" and any("unknown plant" in w[2] for w in r[1]))(run("A,triffid,15,plastic,medium,yes,\n")))
case("species substring match", lambda: run("A,variegated monstera,15,plastic,medium,yes,\n")[0]["plants"][0]["type"] == "top_dry")
case("succulent low light warning", lambda: any("stretch" in w[2] for w in run("A,echeveria,10,terracotta,low,yes,\n")[1]))
case("fern in direct sun warning", lambda: any("scorch" in w[2] for w in run("A,boston fern,20,plastic,direct,yes,\n")[1]))
case("self-watering succulent warning", lambda: any("self-watering" in w[2] for w in run("A,cactus,15,self-watering,bright,yes,\n")[1]))
case("orchid amount is soak", lambda: run("A,orchid,12,plastic,bright,yes,\n")[0]["plants"][0]["amount"] == "soak 10 min")
case("amount scales with pot", lambda: int(run("A,pothos,25,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]) > int(run("A,pothos,12,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]))
case("low humidity shortens", lambda: run("A,fern,20,plastic,medium,yes,\n", humidity="low")[0]["plants"][0]["interval_now"] <= run("A,fern,20,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"])
case("interval never below 1 day", lambda: run("A,basil,6,fabric,direct,yes,\n", start=dt.date(2026, 7, 6), humidity="low")[0]["plants"][0]["interval_now"] >= 1)
case("missing pot size assumed 15", lambda: (lambda r: r[0]["plants"][0]["pot_cm"] == 15 and any("assumed 15" in w[2] for w in r[1]))(run("A,pothos,,plastic,medium,yes,\n")))
def bad(rows):
try:
load_plants(H + rows); return False
except InputError:
return True
case("bad material rejected", lambda: bad("A,pothos,15,wood,medium,yes,\n"))
case("bad light rejected", lambda: bad("A,pothos,15,plastic,dim,yes,\n"))
case("pot in mm rejected", lambda: bad("A,pothos,150,plastic,medium,yes,\n"))
case("bad date rejected", lambda: bad("A,pothos,15,plastic,medium,yes,30/10/2026\n"))
case("ics has one event per date, CRLF, escaped commas", lambda: (lambda r, t: t.count("BEGIN:VEVENT") == len(r["calendar"]) and "\r\n" in t and "\\," in t)(*(lambda r: (r, to_ics(r, "20261030T000000Z")))(run("A,pothos,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0])))
case("ics lines folded to 75 octets", lambda: all(len(l.encode()) <= 75 for l in to_ics(run("".join(f"Plant number {i},pothos,15,plastic,medium,yes,\n" for i in range(8)))[0], "20261030T000000Z").split("\r\n")))
passed = 0
for name, ok, err in cases:
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" {err}"))
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Build a houseplant watering check calendar")
ap.add_argument("plants", nargs="?")
ap.add_argument("--start", help="first day of the plan, YYYY-MM-DD (default today)")
ap.add_argument("--weeks", type=int, default=4)
ap.add_argument("--hemisphere", choices=["north", "south"], default="north")
ap.add_argument("--humidity", choices=list(HUMIDITY), default="normal", help="low = heating on / below ~35 %%")
ap.add_argument("--check-days", help="snap checks to these weekdays, e.g. Mon,Thu")
ap.add_argument("--ics", help="also write an .ics calendar file")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.plants:
ap.error("give a plants CSV, or --self-test")
try:
start = parse_date(a.start, "--start") if a.start else dt.date.today()
if not 1 <= a.weeks <= 26:
raise InputError("--weeks must be 1..26")
days = None
if a.check_days:
days = [x.strip()[:3].title() for x in a.check_days.split(",") if x.strip()]
for x in days:
if x not in DAY_NAMES:
raise InputError(f"unknown weekday '{x}' in --check-days")
with open(a.plants, encoding="utf-8") as fh:
plants, warnings = load_plants(fh.read())
except (OSError, InputError, UnicodeDecodeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
res = plan(plants, start, a.weeks, a.hemisphere, a.humidity, days)
if a.ics:
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
with open(a.ics, "w", encoding="utf-8", newline="") as fh:
fh.write(to_ics(res, stamp))
if a.json:
out = {**res, "start": res["start"].isoformat(), "end": res["end"].isoformat(),
"calendar": [{"date": d.isoformat(), "plants": n} for d, n in res["calendar"]],
"late_snaps": [{"plant": n, "due": d.isoformat(), "planned": p.isoformat()} for n, d, p in res["late_snaps"]],
"plants": [{**p, "last_watered": p["last_watered"].isoformat() if p["last_watered"] else None} for p in res["plants"]],
"warnings": [{"severity": s, "plant": n, "note": m} for s, n, m in warnings]}
print(json.dumps(out, indent=2))
else:
sys.stdout.write(render(res, warnings))
return 1 if any(w[0] == "HIGH" for w in warnings) else 0
if __name__ == "__main__":
sys.exit(main())