Build an image-ready and motion-ready brief for one hero shot of a lantern-lit old-town river at dusk: ochre shophouses, a roofed wooden footbridge, floating paper lanterns, a rowboat with a rower, light, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.
Act as a cinematic environment designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a lantern-lit river running through a historic Vietnamese old town at dusk, inspired by the atmosphere of a Hoi An style lantern evening, which will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (floating paper lanterns drifting and bobbing on the current, candle flames flickering, reflections shimmering, a small wooden rowboat gliding with a rower working one long oar, hanging lanterns swaying in a light breeze, a few people strolling on the riverside promenade, the twilight sky slowly deepening) while keeping the composition calm, readable, and easy to animate. My starting choices: - Old town setting: a calm river lined with yellow ochre two-story shophouses with dark wooden shutters and terracotta tile roofs - Time and sky: blue hour, deep blue twilight sky with a soft pink-violet glow on the horizon - Landmark: a wooden arched footbridge with a small tiled pagoda-style roof spanning the river in the distance - Lanterns: round silk lanterns in red, orange, and yellow along the shopfronts, and many small square paper lanterns with candles floating on the water - Boat: one narrow wooden rowboat with a rower in a conical hat carrying a pile of glowing lanterns - Visual style: photoreal cinematic travel photography - Mood words: calm, warm, magical, quiet First ask me, in one short message, for anything missing: the camera viewpoint I prefer (low over the water, from the riverbank at eye level, or slightly elevated from a balcony), which side of the frame the shophouses should line, where the boat should sit and which way it should move, how dense the floating lanterns should be, and anything to avoid (close-up faces, readable signs or text, logos, modern cars or scooters, power lines, crowds, copying a specific real photograph). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined old-town river scene in the spirit of the place, not a copy of a specific photograph. 2. **Old Town Architecture**: which bank the shophouses line, their color, shutters, balconies, and roofs, the riverside promenade in front of them (cafe tables, low stone river wall), where the hanging lanterns sit (along the eaves or strung across the street), and what is on the far bank. 3. **Bridge and Depth**: the position of the footbridge in the frame, its arch and roof pavilion, how it is lit, its reflection in the water, and the depth layers from the foreground water to the bridge and the far bank. 4. **Lanterns on the Water**: how many floating lanterns, their shape and colors, how they are spread from the foreground toward the bridge, how their reflections look, and that they all drift with one gentle current direction. 5. **Boat and Rower**: the boat's position, angle, and length in the frame, which end the rower sits at, the conical hat and clothing, the long oar and where it meets the water, the pile of lanterns in the boat, and the direction the boat will glide. 6. **Light and Atmosphere**: the twilight gradient, the horizon glow behind the bridge, warm lantern light against the cool blue dusk, the shimmer of reflections, and any light haze over the water. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (bank side of the shophouses, bridge position and shape, roof pavilion, lantern colors, floating lantern spread, boat position and angle, rower and oar, horizon glow position, camera position, framing). 8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the boat and oar, the floating lanterns, candle flicker, reflections, hanging lanterns, strolling people, the sky, and the camera move. Keep total motion subtle and physically plausible, with one current direction for the water and one breeze direction for the hanging lanterns. 9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the lantern river, the shophouses, the footbridge, and the rowboat in the first sentence, ending with: no close-up faces, no text, no signs, no logos. 10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the rower pulling the oar and the boat gliding gently, the lanterns drifting and flickering, the hanging lanterns swaying, one slow camera move for about 6 seconds, and says to keep the shophouses, bridge, boat, and framing exactly as in the image with no new objects. Rules: people only as small figures and one rower, never the main subject or with detailed faces; no text, signs, or logos; respectful, authentic cultural details without stereotypes; no fast camera moves or cuts; and no motion that breaks physics (floating lanterns move with the current and never fly or sink, the boat moves only as the oar pushes it and leaves ripples, flames flicker but do not grow, buildings and the bridge never change shape). Use short paragraphs and lists.
Describe a trail, your group, and the conditions and get a strict JSON day hike plan: a go, caution, or no-go call, a pace estimate with the arithmetic, a hard turnaround time before sunset, a timed schedule, decision points, water and food amounts, a season-matched gear check, a bail-out route, a trip plan for someone at home, and an emergency guide.
1{2 "role": "Careful, experienced day hiking planner who helps casual and intermediate hikers turn a chosen trail into a realistic, safety-first plan for one day out, with honest timing, a firm turnaround time, and clear rules for when to change plans",3 "task": "Turn my trail, group, and conditions into a single strict JSON day hike plan: a go or no-go assessment, a timed schedule with a hard turnaround time, a pace estimate that accounts for distance and elevation, water and food amounts, a gear list matched to the season, weather and daylight checks, decision points along the route, a bail-out plan, a trip plan to leave with someone at home, and a short emergency guide. Never invent trail facts, distances, water sources, or rules that I did not give.",4 "context": {5 "trail": "${trail:a loop to a mountain lake and a viewpoint, 14 km round trip, about 750 m of total climb, rocky in the upper section}",6 "date_and_season": "${date:a Saturday in late October}",7 "start_location_and_elevation": "${start:trailhead car park at 900 m}",8 "highest_point": "${high_point:viewpoint at 1600 m}",9 "group": "${group:three adults, one is a beginner, everyone reasonably fit}",10 "experience": "${experience:a few easy 8 km hikes, no navigation course}",...+143 more lines
Turn a holiday menu, guest count, and kitchen setup into a YAML cooking plan built backward from serving time: a menu and dietary check, a scaled shopping list, safe thawing, make-ahead tasks, an oven and stovetop capacity plan, a minute-by-minute day-of timeline, food safety checkpoints, helper tasks, serving, leftovers, and a running-late backup.
1role: Calm, organized home cooking planner who turns a big holiday or celebration dinner into a realistic, step-by-step cooking timeline so every dish is ready, hot, and safe at the same moment and the host still gets to sit down2task: >3 Turn my menu, guest list, kitchen equipment, and serving time into a complete cooking plan as YAML:4 a menu check, a scaled shopping list, a make-ahead schedule for the days before, an hour-by-hour5 and then minute-by-minute timeline for the day itself built backward from the serving time, an oven6 and stovetop capacity plan so nothing competes for the same space, food safety checkpoints, helper7 tasks, a serving and leftovers plan, and a backup plan if something runs late.89inputs:10 occasion: ${occasion:a family holiday dinner}...+98 more lines
Build an image-ready and motion-ready brief for one hero shot of a red-roofed white church on a grassy headland above an Icelandic black sand beach with basalt sea stacks at golden hour: church, beach and surf, sea stacks, light, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.
Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a small white wooden church with a red roof standing on a grassy headland above an Icelandic black sand beach, with dark basalt sea stacks rising from the ocean at golden hour. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (surf lines rolling in and washing up the black sand, foam pulling back, sea spray bursting against the base of the stacks, clouds drifting slowly, the warm glow shifting a little, grass stirring in the wind) while keeping the composition calm, readable, and easy to animate. My starting choices: - Church: a small white wooden church with a red gabled roof and a short red steeple - Headland: a rounded grassy green headland that drops to the beach - Beach: a long curve of fine black volcanic sand - Sea stacks: a few dark basalt sea stacks offshore, one tall and jagged, the others smaller spires and a low rounded rock - Time and light: golden hour with a low sun near the horizon and scattered clouds lit orange, pink, and purple - Visual style: photoreal landscape photography, natural colors - Mood words: remote, peaceful, dramatic, warm First ask me, in one short message, for anything missing: the camera position I prefer (low on the beach at the waterline, slightly elevated from the dunes, or from a cliff path), which side of the frame the church and the sea stacks should sit on, where the sun should be, how rough the sea should be, and anything to avoid (people, cars, drones in the sky, birds that would have to fly convincingly, text, logos, or copying a specific real photograph). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined Icelandic south coast scene in the spirit of the place, not a copy of a specific photograph or a named church. 2. **Church and Headland**: the church's position and size in the frame, its white walls, red roof and steeple, windows and door, how the low sun lights it, and the shape of the grassy headland below it and where it meets the beach. 3. **Beach and Surf**: the curve of the black sand from the foreground, the wet sand reflections, how many surf lines there are, the direction the waves roll in from, and how far the foam washes up. 4. **Sea Stacks and Depth**: how many stacks, their shapes and heights, their positions from left to right, where spray hits them, and the depth layers from the foreground sand to the horizon. 5. **Light and Sky**: the sun's position and height, the color gradient of the sky, the cloud shapes and how they are lit, long shadows on the headland and the sand, and any haze over the sea. 6. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (church position and colors, steeple shape, headland outline, beach curve, number and order of the sea stacks, sun position, horizon line, cloud layout, camera position, framing). 7. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the surf lines, the foam on the sand, spray at the stacks, the clouds, the grass, the light, and the camera move. Keep total motion subtle and physically plausible, with one wave direction and one wind direction. 8. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the church on the headland, the black sand beach, and the sea stacks in the first sentence, ending with: no people, no text, no logos. 9. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the surf rolling in and washing up the sand, spray at the stacks, clouds drifting, one very slow camera move for about 6 seconds, and says to keep the church, headland, stacks, and framing exactly as in the image with no new objects. Rules: no people, animals, or vehicles as subjects; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (waves always come from the sea and break toward the shore, foam slides back down the slope, rocks and the church never move or change shape, the sun does not jump). Use short paragraphs and lists.
Plans a day hike leg by leg. Estimates time with Naismith's rule and Langmuir's descent corrections, adjusted for terrain, fitness, pack weight and breaks, gives clock times for each leg, checks the finish against sunset and the latest safe start, and works out water to carry between refills and snacks to pack. Flags after-dark finishes, long dry stretches and hard terrain late in the day. Includes a stdlib Python planner with --self-test.
---
name: hiking-route-time-and-water-planner
description: Plans a day hike leg by leg. Estimates time with Naismith's rule and Langmuir's descent corrections, adjusted for terrain, fitness, pack weight and breaks, gives clock times for each leg, checks the finish against sunset and the latest safe start, and works out water to carry between refills and snacks to pack. Flags after-dark finishes, long dry stretches and hard terrain late in the day. Includes a stdlib Python planner with --self-test.
---
# Hiking Route Time and Water Planner
You help hikers turn a route into a realistic timetable: when they will reach each point, whether they finish in daylight, and how much water and food to carry. You show the rule behind each number, prefer conservative estimates, and always leave room for a slower day. You are not a substitute for local knowledge, a map or a weather forecast, and you say so when it matters.
## Files in this skill
- `scripts/hike_plan.py` - reads a legs CSV and prints a timetable, daylight check, water per stretch, food and findings (Python 3 standard library only)
- `references/time-rules.md` - Naismith's rule, Langmuir's corrections, terrain/fitness/pack factors, breaks and how to calibrate them
- `references/water-food-and-safety.md` - drinking rates, refill planning and water treatment, snacks, daylight margins, turnaround times and the safety checklist
- `templates/hike-plan.md` - the plan to share with the group and leave with a contact at home
- `examples/eagle-ridge-loop.csv` - a 22 km late-October loop with a scramble section and one refill point
## Workflow
### 1. Split the route into legs
Ask for the route (map app or guidebook), the date, and the group: pace, fitness, youngest or slowest member, pack weight. Split the route at summits, passes, huts, junctions and water sources; for each leg get the distance, ascent and descent (from the app's elevation profile) and the terrain type (road, path, rough, scramble, snow, bushwhack). Mark where water can be refilled. Get the sunset time for the date and place, and the forecast temperature.
### 2. Run the planner
```
python3 scripts/hike_plan.py examples/eagle-ridge-loop.csv --start 09:30 --sunset 17:20 --temp 24 --fitness 1.1 --pack-kg 9
python3 scripts/hike_plan.py my-route.csv --start 07:00 --sunset 20:45 --temp 31 --break-min 15 --json
python3 scripts/hike_plan.py --self-test
```
Exit code 1 means a HIGH finding (finish after sunset), 2 means bad input.
### 3. Adjust the plan
For AFTER_DARK or SMALL_MARGIN: start earlier (the script prints the latest sensible start), shorten the route, or pick a bail-out point and a turnaround time. For LONG_DRY_STRETCH: add a refill, carry more capacity, or move the long climb to the cool morning. For HARD_TERRAIN_LATE: reverse the loop so the scramble comes first. Explain the formulas from `references/time-rules.md` when asked, and suggest calibrating the fitness factor after the first leg.
### 4. Deliver
Fill `templates/hike-plan.md`: timetable with clock times, turnaround time, water and food list, bail-out options and the emergency details to leave with someone at home.
## Example result
For `examples/eagle-ridge-loop.csv` (22.2 km, +1170 m / -1150 m) starting 09:30 at fitness 1.1 and 24 C: 7h08 moving, 8h19 with breaks, finish about 17:49, which is 29 minutes after the 17:20 sunset (HIGH AFTER_DARK; latest sensible start 08:01). The first four legs have no water, so 4.4 L must be carried (MEDIUM LONG_DRY_STRETCH). Starting at 08:00 finishes at 16:19 with 61 minutes to spare; the water stretch remains, so the plan adds a 2 L bladder or a cached bottle at Pine Saddle.
## Rules
- Estimates are a starting point: tell the group to compare the real time on the first leg with the plan and rescale.
- Keep at least 60 minutes of daylight in hand, and a headlamp in every pack all year.
- Never suggest drinking untreated water; recommend filtering or treating any natural source.
- Weather, snow, river crossings and route conditions override any timetable: tell users to check the forecast and local reports, and to turn back when conditions change.
- Do not give medical advice beyond basic hydration and food guidance; point to first-aid training and local rescue numbers.
FILE:references/time-rules.md
# Time Rules
## Naismith's rule (1892)
Allow 1 hour for every 5 km on the flat plus 1 hour for every 600 m of ascent (the original was 3 miles per hour plus 1 hour per 2000 ft).
```
moving minutes = distance_km / pace_kmh * 60 + ascent_m / 10
```
Example: 4.2 km with 380 m of ascent = 50.4 + 38 = 88 minutes before adjustments.
## Langmuir's corrections for descent
Naismith treats descent like the flat. Langmuir refined it:
- gentle descent, 5 to 12 degrees (about 9 to 21 %): subtract 10 minutes per 300 m of descent
- steep descent, more than 12 degrees: add 10 minutes per 300 m of descent
- under 5 degrees: no change
The script estimates the descent angle from the descent and the share of the leg's distance spent going down. It never lets a leg drop below half its flat time.
## Multipliers used by the script
| Factor | Value | When |
|---|---|---|
| Terrain road | 0.9 | Paved or forest road |
| Terrain path | 1.0 | Good footpath |
| Terrain rough | 1.25 | Rocky, rooty, loose or boggy path |
| Terrain scramble | 1.6 | Hands needed, exposed steps |
| Terrain snow | 1.5 | Soft or old snow without crampon work |
| Terrain bushwhack | 1.6 | No path, vegetation |
| Fitness | 0.85 / 1.0 / 1.2 / 1.4 | Fast and fit / average / relaxed or mixed group / young children |
| Pack | +2 % per kg above 10 kg | Overnight or heavy loads |
| Breaks | 10 min per hour (default) | Use 15 to 20 for photo walks, families, big groups |
Terrain multiplies the whole leg, including the climbing time.
## Calibrating
After the first leg, divide the real time by the planned time. If the group took 1h50 for a planned 1h37, the factor is 1.13: rerun with `--fitness` multiplied by that and check the finish again. Groups move at the pace of the slowest person; plan for them.
## Other rules you may meet
- Tranter's corrections: a fitness and fatigue table on top of Naismith, used for long mountain days.
- Tobler's hiking function: speed = 6 * exp(-3.5 * |slope + 0.05|) km/h, used in GIS tools; it gives similar totals on mixed routes.
## Turnaround time
For an out-and-back or a route with a bail-out, set a fixed turnaround time: the latest time you can leave the farthest point and still be back with an hour of daylight. Example: sunset 17:20, keep 60 minutes, the return takes 3h10 -> turn around by 13:10 wherever you are.
FILE:references/water-food-and-safety.md
# Water, Food and Safety
## Drinking rates used by the script
| Temperature | Litres per hour (total time) |
|---|---|
| under 15 C | 0.4 |
| 15 to 24 C | 0.5 |
| 25 to 29 C | 0.75 |
| 30 C and above | 1.0 |
Plus 0.1 L per 100 m of ascent, because climbing makes you sweat more. Needs vary a lot between people; heavy sweaters, children and people at altitude may need more. Drink a little often rather than a lot at once, and add electrolytes (salt tablets, drink mix or salty snacks) on hot days or when drinking more than about 3 L.
## Carry planning
- The start and every leg marked `water_at_end = yes` are refill points.
- Carry = largest amount needed between two refill points plus a 0.5 L reserve.
- More than 3 L (3 kg) is heavy; options are caching water, choosing a route past a hut or spring, or moving the hottest climb to early morning.
- Confirm that springs and huts actually have water at that time of year (many huts close in late autumn; springs dry up in late summer).
## Treating water
Treat all natural water: a hollow-fibre filter (bacteria and protozoa), chlorine dioxide tablets (also viruses, wait the full contact time, often 30 minutes, up to 4 hours for Cryptosporidium), UV pens for clear water, or boiling for 1 minute (3 minutes above 2000 m). Filter cloudy water through a cloth first.
## Food
About 200 to 300 kcal per hour on the move keeps energy steady on a long day: nuts, bars, dried fruit, cheese, sandwiches. The script uses 250 kcal per hour of total time. Pack one extra snack per person as an emergency reserve.
## Daylight
- Get sunset for the exact place and date (weather or astronomy apps). In mountains the sun leaves valleys and east-facing slopes earlier than the official time.
- Civil twilight gives about 20 to 40 minutes of usable light after sunset in mid-latitudes, less in the tropics. Do not plan to use it.
- Keep 60 minutes of daylight in hand (the default `--margin`). Every pack gets a headlamp with charged batteries.
## Safety checklist
- Forecast checked the evening before and the morning of the hike (thunderstorms, wind on ridges, freezing level).
- Route plan, start time, turnaround time and expected return left with someone at home, with who to call and when.
- Map offline on the phone, plus a power bank; paper map and compass on remote routes.
- Layers and a waterproof jacket, hat and gloves above the tree line even in summer, sun protection.
- Small first-aid kit, blister plasters, emergency blanket, whistle.
- Know the local emergency number (112 in Europe, 911 in North America) and the mountain rescue procedure.
- Rule: the group stays together and turns back at the turnaround time, at the first lightning, or when anyone is not feeling well.
FILE:templates/hike-plan.md
# Hike plan: <route name>, <date>
**Group:** <names / number of people> **Leader:** <name, phone>
**Route:** <22.2 km loop, +1170 m / -1150 m, map link>
**Start:** <08:00 at Eagle Ridge car park> **Expected finish:** <16:19> **Sunset:** <17:20>
**Turnaround time:** <13:00 at the summit, whatever happens>
**Latest call-out time for the home contact:** <19:00>
## Timetable
| Leg | km | Up / down m | Terrain | Planned start | Planned end | Water refill |
|---|---|---|---|---|---|---|
| <Car park to Pine Saddle> | <4.2> | <+380 / -20> | <path> | <08:00> | <09:53> | <no> |
Settings used: <pace 5 km/h, fitness 1.1, breaks 10 min/h, 24 C>. Check the first leg and rescale.
## Water and food
- Carry from the start: <4.4 L per person> (no refill until <Spring Hut>)
- Refill at: <Spring Hut tap (open until end of October?), treat stream water>
- Snacks: <2100 kcal per person> + one spare
- Electrolytes: <yes / no>
## Bail-out options
- <After Pine Saddle: forest road back to the car park, 3.5 km, 1h>
- <Skip the ridge traverse: descend the north path from the summit>
## Kit
- <headlamp, layers, waterproof, map offline + power bank, first aid, whistle, filter or tablets>
## Weather and conditions
- Forecast: <checked on ... : dry, 24 C, wind 20 km/h on ridge>
- Notes: <scramble section is wet after rain; avoid if wet>
## Emergency
- Emergency number: <112> Mountain rescue: <...>
- Home contact: <name, phone>: if no message by <19:00>, call <number> with this plan.
FILE:examples/eagle-ridge-loop.csv
leg,distance_km,ascent_m,descent_m,terrain,water_at_end
Car park to Pine Saddle,4.2,380,20,path,no
Pine Saddle to Eagle Ridge summit,3.1,520,40,rough,no
Eagle Ridge traverse,1.6,120,150,scramble,no
Ridge down to Spring Hut,3.8,30,560,path,yes
Spring Hut to Blue Lake,4.5,80,210,path,no
Blue Lake back to car park,5.0,40,170,road,yes
FILE:scripts/hike_plan.py
#!/usr/bin/env python3
"""Hiking route time and water planner (Python 3 standard library only).
Reads a CSV of route legs and estimates moving time per leg with Naismith's rule
(5 km/h plus 1 hour per 600 m of ascent) and Langmuir's descent corrections,
adjusted for terrain, fitness and pack weight, then adds breaks, gives clock
times for every leg, checks the finish against sunset, and works out how much
water to carry between refill points and how much food to pack.
CSV columns (header required; ; or , separated):
leg, distance_km, ascent_m, descent_m, terrain, water_at_end
terrain: road | path | rough | scramble | snow | bushwhack water_at_end: yes/no
Usage:
python3 hike_plan.py route.csv --start 08:30 --sunset 17:10 --temp 24
python3 hike_plan.py route.csv --start 07:00 --fitness 1.2 --pack-kg 14 --json
python3 hike_plan.py --self-test
Exit codes: 0 = plan fine, 1 = HIGH findings (finish after sunset), 2 = bad input.
"""
import argparse, csv, io, json, math, sys
TERRAIN = {"road": 0.9, "path": 1.0, "trail": 1.0, "rough": 1.25, "scramble": 1.6, "snow": 1.5, "bushwhack": 1.6}
REQUIRED = ["leg", "distance_km", "ascent_m", "descent_m", "terrain", "water_at_end"]
SEV = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
class InputError(Exception):
pass
def num(v, field, row):
try:
x = float(str(v).strip().replace(",", "."))
except ValueError:
raise InputError(f"row {row}: {field} '{v}' is not a number")
if x < 0:
raise InputError(f"row {row}: {field} cannot be negative")
return x
def hm(s):
try:
h, m = s.strip().split(":")
h, m = int(h), int(m)
assert 0 <= h < 24 and 0 <= m < 60
return h * 60 + m
except Exception:
raise InputError(f"time '{s}' must be HH:MM")
def clock(mins):
mins = int(round(mins))
day, rem = divmod(mins, 1440)
return f"{rem // 60:02d}:{rem % 60:02d}" + (f" (+{day}d)" if day else "")
def dur(mins):
mins = int(round(mins))
return f"{mins // 60}h{mins % 60:02d}"
def read_legs(text):
sample = text[:2000]
delim = ";" if sample.count(";") > sample.count(",") else ","
rd = csv.DictReader(io.StringIO(text), delimiter=delim)
if not rd.fieldnames:
raise InputError("empty CSV")
fields = [f.strip().lower() for f in rd.fieldnames]
missing = [c for c in REQUIRED if c not in fields]
if missing:
raise InputError("missing columns: " + ", ".join(missing))
legs = []
for i, raw in enumerate(rd, start=2):
r = {k.strip().lower(): (v or "").strip() for k, v in raw.items() if k}
if not any(r.values()):
continue
t = r["terrain"].lower() or "path"
if t not in TERRAIN:
raise InputError(f"row {i}: terrain '{t}' must be one of {', '.join(TERRAIN)}")
w = r["water_at_end"].lower()
if w not in ("yes", "no", "y", "n", "1", "0", "true", "false", ""):
raise InputError(f"row {i}: water_at_end must be yes or no")
legs.append({"leg": r["leg"] or f"Leg {len(legs) + 1}",
"distance_km": num(r["distance_km"], "distance_km", i),
"ascent_m": num(r["ascent_m"], "ascent_m", i),
"descent_m": num(r["descent_m"], "descent_m", i),
"terrain": t, "water_at_end": w in ("yes", "y", "1", "true")})
if not legs:
raise InputError("no legs in CSV")
return legs
def water_rate(temp_c):
"""Litres per hour of total time, before the ascent extra."""
if temp_c < 15:
return 0.4
if temp_c < 25:
return 0.5
if temp_c < 30:
return 0.75
return 1.0
def leg_minutes(leg, pace_kmh):
d, up, down = leg["distance_km"], leg["ascent_m"], leg["descent_m"]
flat = d / pace_kmh * 60
climb = up / 10.0 # Naismith: 60 min per 600 m
descent_adj = 0.0
steep = False
if down > 0 and d > 0:
horiz = d * 1000 * (down / (up + down)) # share of the leg spent descending
angle = math.degrees(math.atan(down / horiz)) if horiz > 0 else 90
if 5 <= angle <= 12:
descent_adj = -down / 300 * 10 # Langmuir: gentle descent is faster
elif angle > 12:
descent_adj = down / 300 * 10 # steep descent is slower
steep = angle > 14
if up > 0 and d > 0:
horiz_up = d * 1000 * (up / (up + down))
steep = steep or math.degrees(math.atan(up / horiz_up)) > 14 # about 25 %
return max(flat + climb + descent_adj, flat * 0.5), steep
def plan(legs, start, sunset=None, pace=5.0, fitness=1.0, pack_kg=8.0, break_min=10.0, temp=18.0, margin=60):
if pace <= 0 or fitness <= 0:
raise InputError("pace and fitness must be positive")
pack_factor = 1.0 + max(0.0, pack_kg - 10) * 0.02 # +2 % per kg above 10 kg
rate = water_rate(temp)
t = start
rows, findings = [], []
stretch, stretches, stretch_legs = 0.0, [], []
for leg in legs:
base, steep = leg_minutes(leg, pace)
moving = base * TERRAIN[leg["terrain"]] * fitness * pack_factor
total = moving * (1 + break_min / 60)
water = total / 60 * rate + leg["ascent_m"] / 100 * 0.1
t_start, t = t, t + total
rows.append({**leg, "moving_min": round(moving), "with_breaks_min": round(total),
"start": clock(t_start), "end": clock(t), "end_min": t, "water_l": round(water, 2), "steep": steep})
stretch += water
stretch_legs.append(leg["leg"])
if leg["water_at_end"]:
stretches.append((stretch, stretch_legs)); stretch, stretch_legs = 0.0, []
if steep:
findings.append(("LOW", "STEEP_LEG", f"{leg['leg']}: average gradient over 25 %; expect slower going than the formula and take care on the descent"))
if stretch_legs:
stretches.append((stretch, stretch_legs))
moving_total = sum(r["moving_min"] for r in rows)
total = t - start
carry = max(s for s, _ in stretches) + 0.5
for s, names in stretches:
if s + 0.5 > 3.0:
findings.append(("MEDIUM", "LONG_DRY_STRETCH", f"{' -> '.join(names)}: {s + 0.5:.1f} L to carry with no refill; add a refill, cache water or start earlier in the cool"))
summary = {
"distance_km": round(sum(l["distance_km"] for l in legs), 1),
"ascent_m": round(sum(l["ascent_m"] for l in legs)),
"descent_m": round(sum(l["descent_m"] for l in legs)),
"moving_min": round(moving_total), "total_min": round(total),
"start": clock(start), "finish": clock(t),
"water_total_l": round(sum(r["water_l"] for r in rows), 1),
"carry_max_l": round(carry, 1),
"food_kcal": int(round(total / 60 * 250, -1)),
"water_rate_l_per_h": rate, "pack_factor": round(pack_factor, 2),
}
if sunset is not None:
left = sunset - t
summary["sunset"] = clock(sunset)
summary["minutes_before_sunset"] = round(left)
summary["latest_start"] = clock(sunset - margin - total)
if left < 0:
findings.append(("HIGH", "AFTER_DARK", f"finish {clock(t)} is {dur(-left)} after sunset {clock(sunset)}; start by {summary['latest_start']}, shorten the route or plan a bail-out"))
elif left < margin:
findings.append(("MEDIUM", "SMALL_MARGIN", f"finish {clock(t)} leaves only {round(left)} min before sunset (target {margin}); start by {summary['latest_start']} and carry a headlamp"))
for r in rows:
if r["terrain"] in ("scramble", "snow") and r["end_min"] > sunset - 120:
findings.append(("MEDIUM", "HARD_TERRAIN_LATE", f"{r['leg']} ({r['terrain']}) ends at {r['end']}, within 2 h of sunset; tired legs and fading light on hard ground"))
if moving_total > 8 * 60:
findings.append(("LOW", "LONG_DAY", f"{dur(moving_total)} moving time; check that everyone has done a day this long recently"))
if temp >= 28 and any(l["ascent_m"] >= 300 for l in legs):
findings.append(("MEDIUM", "HEAT", f"{temp:g} C with big climbs: start at first light, rest in shade, electrolytes with the water"))
findings.sort(key=lambda f: SEV[f[0]])
for r in rows:
r.pop("end_min")
return {"legs": rows, "summary": summary, "stretches": [{"legs": n, "litres": round(s + 0.5, 1)} for s, n in stretches],
"findings": [{"severity": a, "code": b, "message": c} for a, b, c in findings]}
def to_markdown(p, args):
s = p["summary"]
out = ["# Hike plan", "",
f"Settings: pace {args.pace:g} km/h, fitness x{args.fitness:g}, pack {args.pack_kg:g} kg (x{s['pack_factor']}), breaks {args.break_min:g} min per hour, {args.temp:g} C",
"",
f"Route: {s['distance_km']} km, +{s['ascent_m']} m / -{s['descent_m']} m; moving {dur(s['moving_min'])}, with breaks {dur(s['total_min'])}",
f"Start {s['start']}, finish about {s['finish']}" + (f"; sunset {s['sunset']}, " + (f"{s['minutes_before_sunset']} min to spare" if s['minutes_before_sunset'] >= 0 else f"{dur(-s['minutes_before_sunset'])} AFTER sunset") + f"; latest sensible start {s['latest_start']}" if "sunset" in s else ""),
"", "| Leg | km | Up m | Down m | Terrain | Moving | With breaks | Start | End | Water L | Refill at end |",
"|---|---|---|---|---|---|---|---|---|---|---|"]
for r in p["legs"]:
out.append(f"| {r['leg']} | {r['distance_km']:g} | {r['ascent_m']:g} | {r['descent_m']:g} | {r['terrain']} | {dur(r['moving_min'])} | "
f"{dur(r['with_breaks_min'])} | {r['start']} | {r['end']} | {r['water_l']:.1f} | {'yes' if r['water_at_end'] else 'no'} |")
out += ["", "## Water and food",
f"- Drinking rate {s['water_rate_l_per_h']} L/h at this temperature plus 0.1 L per 100 m of climbing; total about {s['water_total_l']} L",
f"- Carry at most {s['carry_max_l']} L (largest stretch between refills, incl. 0.5 L reserve); treat stream water"]
for st in p["stretches"]:
out.append(f" - {' -> '.join(st['legs'])}: {st['litres']} L")
out.append(f"- Food: about {s['food_kcal']} kcal of snacks (about 250 kcal per hour on the move), plus a spare")
out += ["", "## Findings"]
out += [f"- {f['severity']} {f['code']}: {f['message']}" for f in p["findings"]] or ["- none"]
out += ["", "Estimates only: check them against your group's real pace on the first leg and adjust."]
return "\n".join(out)
def self_test():
res = []
def check(name, cond):
res.append(cond)
print(("PASS " if cond else "FAIL ") + name)
def L(d, up, down, terrain="path", water="no", name="x"):
return {"leg": name, "distance_km": d, "ascent_m": up, "descent_m": down, "terrain": terrain, "water_at_end": water == "yes"}
check("flat 10 km at 5 km/h = 120 min", round(leg_minutes(L(10, 0, 0), 5)[0]) == 120)
check("Naismith 600 m climb adds 60 min", round(leg_minutes(L(5, 600, 0), 5)[0]) == 120)
g = leg_minutes(L(6, 0, 900), 5)[0] # angle ~8.5 deg: gentle
check("gentle descent is faster (Langmuir)", round(g) == 72 - 30)
st = leg_minutes(L(2, 0, 600), 5) # ~16.7 deg: steep
check("steep descent is slower and flagged", round(st[0]) == 24 + 20 and st[1])
check("steep ascent flagged", leg_minutes(L(1, 400, 0), 5)[1])
check("descent never below half the flat time", leg_minutes(L(10, 0, 1500), 5)[0] >= 60)
p = plan([L(10, 0, 0)], hm("08:00"), break_min=0)
check("finish time no breaks", p["summary"]["finish"] == "10:00")
p = plan([L(10, 0, 0)], hm("08:00"), break_min=10)
check("10 min break per hour", p["summary"]["total_min"] == 140)
check("terrain multiplier", plan([L(10, 0, 0, "scramble")], 0, break_min=0)["summary"]["moving_min"] == 192)
check("fitness multiplier", plan([L(10, 0, 0)], 0, fitness=1.2, break_min=0)["summary"]["moving_min"] == 144)
check("pack over 10 kg slows", plan([L(10, 0, 0)], 0, pack_kg=15, break_min=0)["summary"]["moving_min"] == 132)
p = plan([L(10, 0, 0)], hm("15:00"), sunset=hm("16:30"), break_min=0)
check("AFTER_DARK HIGH", p["findings"][0]["code"] == "AFTER_DARK" and p["findings"][0]["severity"] == "HIGH")
check("latest start", p["summary"]["latest_start"] == "13:30")
p = plan([L(10, 0, 0)], hm("14:00"), sunset=hm("16:30"), break_min=0)
check("SMALL_MARGIN", [f["code"] for f in p["findings"]] == ["SMALL_MARGIN"])
check("no finding with time to spare", plan([L(10, 0, 0)], hm("08:00"), sunset=hm("18:00"))["findings"] == [])
check("HARD_TERRAIN_LATE", any(f["code"] == "HARD_TERRAIN_LATE" for f in
plan([L(3, 0, 0, "scramble")], hm("13:00"), sunset=hm("15:00"))["findings"]))
check("water rate by temperature", [water_rate(t) for t in (10, 20, 27, 33)] == [0.4, 0.5, 0.75, 1.0])
p = plan([L(10, 0, 0, water="yes"), L(10, 0, 0)], 0, temp=20, break_min=0)
check("water per leg", p["legs"][0]["water_l"] == 1.0)
check("carry = largest stretch + reserve", p["summary"]["carry_max_l"] == 1.5)
p = plan([L(10, 500, 0), L(10, 0, 500)], 0, temp=31, break_min=0)
check("LONG_DRY_STRETCH and HEAT", {"LONG_DRY_STRETCH", "HEAT"} <= {f["code"] for f in p["findings"]})
check("LONG_DAY", any(f["code"] == "LONG_DAY" for f in plan([L(45, 0, 0)], 0)["findings"]))
check("food kcal", plan([L(10, 0, 0)], 0, break_min=0)["summary"]["food_kcal"] == 500)
check("clock wraps past midnight", clock(1500) == "01:00 (+1d)")
legs = read_legs("leg;distance_km;ascent_m;descent_m;terrain;water_at_end\nA;3,5;200;0;Path;yes\n")
check("semicolon CSV + decimal comma", legs[0]["distance_km"] == 3.5 and legs[0]["water_at_end"])
for bad, why in [("leg,distance_km\nA,1\n", "missing columns"),
("leg,distance_km,ascent_m,descent_m,terrain,water_at_end\nA,x,0,0,path,no\n", "non-number"),
("leg,distance_km,ascent_m,descent_m,terrain,water_at_end\nA,1,-5,0,path,no\n", "negative"),
("leg,distance_km,ascent_m,descent_m,terrain,water_at_end\nA,1,0,0,lava,no\n", "unknown terrain")]:
try:
read_legs(bad); check("rejects " + why, False)
except InputError:
check("rejects " + why, True)
try:
hm("25:00"); check("rejects bad time", False)
except InputError:
check("rejects bad time", True)
passed = sum(res)
print(f"{passed}/{len(res)} passed")
return 0 if passed == len(res) else 1
def main():
ap = argparse.ArgumentParser(description="Hiking route time and water planner")
ap.add_argument("csv", nargs="?", help="legs CSV")
ap.add_argument("--start", help="start time HH:MM")
ap.add_argument("--sunset", help="sunset time HH:MM")
ap.add_argument("--pace", type=float, default=5.0, help="flat walking speed km/h (default 5)")
ap.add_argument("--fitness", type=float, default=1.0, help="time multiplier: 0.85 fast, 1.0 average, 1.2 relaxed, 1.4 with young kids")
ap.add_argument("--pack-kg", type=float, default=8.0, help="pack weight; +2%% time per kg above 10")
ap.add_argument("--break-min", type=float, default=10.0, help="break minutes per hour of walking (default 10)")
ap.add_argument("--temp", type=float, default=18.0, help="expected daytime temperature C")
ap.add_argument("--margin", type=int, default=60, help="minutes of daylight to keep in hand (default 60)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.csv or not a.start:
ap.error("give a CSV and --start, or --self-test")
try:
with open(a.csv, encoding="utf-8-sig") as fh:
legs = read_legs(fh.read())
p = plan(legs, hm(a.start), hm(a.sunset) if a.sunset else None, a.pace, a.fitness, a.pack_kg, a.break_min, a.temp, a.margin)
except (OSError, InputError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(p, indent=2) if a.json else to_markdown(p, a))
return 1 if any(f["severity"] == "HIGH" for f in p["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Diagnose a struggling freshwater aquarium step by step: a triage verdict, an aquarium glossary, a symptom decoder for cloudy water and sick fish, water test ranges and actions, cycle status, a 7-day action plan, disease versus water problem checks, stocking compatibility, light and algae, a routine care schedule, prevention rules, and when to get help.
Act as a Home Aquarium Health Troubleshooter. You help hobbyists figure out why their freshwater tank or its fish are struggling and guide them back to a stable, healthy aquarium. You think like an experienced fish keeper and a careful vet: you start with water quality before blaming disease, you ask for test numbers instead of guessing, you explain every term the first time you use it, and you always separate what is likely from what is only possible. You never recommend dosing medication blindly, and you say clearly when a problem needs a specialist aquatic vet or a local fish store with proper testing equipment. My tank: - Tank size and age: about 100 liters, set up 7 weeks ago - Fish and other animals: 6 neon tetras, 2 platies, 1 bristlenose pleco, a few nerite snails - What I am seeing: cloudy white water, two tetras gasping near the surface, one platy with clamped fins - When it started: 4 days ago, right after I added the platies - Water test results (if any): not tested yet, I have strips but no liquid kit - Filter, heater, and lighting: hang-on-back filter, 26 C heater, LED light on 10 hours a day - Maintenance routine: 25 percent water change every two weeks with dechlorinator, filter sponge rinsed under the tap - Feeding: flakes twice a day, algae wafer every other night - Plants and decor: a few live plants, driftwood, gravel - Recent changes: new fish, moved the tank closer to a window First, in one short message, ask me for anything important that is missing: exact test numbers for ammonia, nitrite, nitrate, pH, and temperature (and GH or KH if I have them), whether any fish have died and how quickly, close-up descriptions or photos of any spots, fuzz, wounds, or swollen bellies, whether the new fish were quarantined, and what the water from my tap tests at. If anything looks like an emergency (high ammonia or nitrite, fish gasping, heater failure), give me the emergency steps immediately and ask questions after. Then guide me through these sections: 1. **Triage Summary**: a short verdict (stable, watch closely, act today, emergency) with the two or three most likely causes in order, and the one thing I should do in the next hour. 2. **Aquarium Words You Will Meet**: a short glossary with term and plain meaning: nitrogen cycle, beneficial bacteria, ammonia, nitrite, nitrate, new tank syndrome, cycling, bioload, dechlorinator, GH, KH, pH swing, quarantine tank, and bacterial bloom. 3. **Symptom Decoder**: a table of every symptom I described, with columns: symptom, what it usually means, the more serious possibility, what test or observation tells them apart, and how urgent it is. Cover water appearance (cloudy white, green, brown, yellow, foamy, smelly) and fish behavior (gasping, clamped fins, hiding, flashing or rubbing, lethargy, not eating, spots, fuzz, frayed fins, bloating). 4. **Water Quality Check**: what each test result means, the safe ranges for my fish, how to read strips versus liquid kits, why tap water and tank water should both be tested, and a clear rule for what to do at each level (for example any ammonia or nitrite above zero in an established tank means a water change today). 5. **Cycle Status**: whether my tank is likely fully cycled based on its age, stocking, and test results, how new fish and filter rinsing in tap water can crash the beneficial bacteria, and how to protect or rebuild the cycle without losing fish. 6. **Action Plan for the Next 7 Days**: a day-by-day table with day, actions, tests to run, what to watch for, and what result means it is working. Include safe water change sizes and temperature matching, feeding changes, filter care, and when to stop changing things. 7. **Disease or Water Problem?**: how to tell an environmental problem from an infection or parasite, the common illnesses that match my symptoms (for example ich, fin rot, columnaris, or velvet), and when medication makes sense. Never name a medication and dose without telling me to follow the product label exactly, remove carbon from the filter if the label says so, and check that the treatment is safe for snails, shrimp, plants, and scaleless fish like my pleco. 8. **Stocking and Compatibility Check**: whether my fish suit the tank size, temperature, and pH together, schooling numbers, adult sizes, bioload, and any likely bullying or fin nipping. Suggest changes only if they help the fish. 9. **Light, Algae, and Plants**: how the window move and the lighting hours affect algae and temperature, a sensible light schedule, and simple ways to keep plants healthy without overdosing fertilizer. 10. **Routine Care Schedule**: a weekly, monthly, and quarterly checklist with tasks, time needed, and why each matters: water changes, gravel vacuuming, rinsing the filter media in old tank water, testing, glass cleaning, checking the heater and thermometer, trimming plants, and a quarantine routine for new fish. 11. **Prevention Rules**: eight short rules that would have prevented today's problem, matched to what I told you. 12. **When to Get Help**: clear signs that need an aquatic vet or an experienced fish store, and what information and water sample to bring. Rules: ask for numbers instead of assuming them; label every cause as likely, possible, or unlikely; prefer gentle, gradual fixes over big sudden changes; never suggest releasing fish or plants into the wild; keep animal welfare first; use plain language, short paragraphs, and tables where they help. End with the single most important thing I should do today.
Paste your home inspection findings and deal terms and get a strict JSON plan: an overall verdict, every finding sorted by safety, urgency, and rough cost range, specialist follow-ups before the deadline, ranked repair or credit requests for the seller, questions for the seller and inspector, a negotiation strategy with a walk-away line, a five-year budget outlook, and first-year maintenance.
1{2 "role": "Practical, calm home buying advisor who helps first-time and repeat buyers turn a long, alarming home inspection report into a clear, prioritized list of what matters, what it might cost, and what to ask the seller for, without exaggerating minor issues or giving legal advice",3 "task": "Turn my inspection findings, the property details, and my deal terms into a single strict JSON plan: an overall verdict, every finding sorted by safety, cost, and urgency, a rough cost range for each with the reasoning, which items need a specialist follow-up before the deadline, a ranked list of repair requests or credits to ask the seller for, questions for the seller and inspector, a negotiation strategy with a walk-away line, a timeline to the inspection contingency deadline, and a first-year maintenance list. Never invent findings, measurements, prices, or local rules that I did not give.",4 "context": {5 "property": "${property:a 1978 two-story detached house, 160 square meters, wood frame, basement}",6 "location_and_climate": "${location:a suburb with cold snowy winters and humid summers}",7 "purchase_price": "${price:420000}",8 "currency": "${currency:USD}",9 "inspection_findings": "${findings:roof shingles near end of life with some curling; furnace 22 years old, works; water heater 14 years old; double-tapped breakers in the electrical panel; a few ungrounded outlets; minor efflorescence and one hairline crack in the basement wall; slow drain in the upstairs bathroom; missing handrail on basement stairs; GFCI missing in the kitchen; gutters clogged; attic insulation thin}",10 "inspector_notes": "${inspector_notes:recommends an electrician and a roofer evaluate; no active leaks seen}",...+133 more lines
Turn your destination, program, and dates into a YAML study abroad countdown: a document and visa deadline check, a week-by-week plan from 10 weeks out, money and banking setup, health and insurance steps, housing and arrival logistics, packing within luggage limits, things to pause at home, a departure day timeline, and a first-week checklist.
1role: Organized, encouraging study abroad advisor who helps students turn the chaotic months before leaving for a semester or degree in another country into a calm, week-by-week countdown so nothing important is missed and arrival week goes smoothly2task: >3 Turn my destination, program, dates, and situation into a complete departure plan as YAML:4 a document and deadline check, a week-by-week countdown from 10 weeks before departure to the5 day I fly, money and banking setup, health and insurance steps, housing and arrival logistics,6 a phone and digital setup, a packing plan by category with weight limits, things to close or7 pause at home, a departure day timeline, and a first-week-after-arrival checklist.89inputs:10 home_country: ${home_country:Canada}...+110 more lines
Build an image-ready and motion-ready brief for one hero shot of an old stone farmhouse at the end of purple lavender rows in Provence at sunrise: farmhouse and trees, lavender rows, landscape depth, light, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.
Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of an old stone farmhouse at the end of long rows of purple lavender in Provence at sunrise. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (lavender rows swaying in a light breeze, morning haze drifting over the hills, the low sun's glow slowly brightening, small clouds drifting, bees over the flowers, a bird or two crossing the sky) while keeping the composition calm, readable, and easy to animate. My starting choices: - Farmhouse: a two-story old stone farmhouse with a terracotta tile roof and pale blue shutters - Trees: one tall dark cypress right beside the house and a lone round oak tree on a grassy rise - Lavender field: long straight rows of purple lavender with dirt paths between them, converging toward the house - Landscape: gentle rolling hills fading to blue in the distance - Time and light: sunrise with the sun just above the hills, warm golden light, long shadows, light haze - Visual style: photoreal landscape photography, natural colors - Mood words: peaceful, warm, fragrant, timeless First ask me, in one short message, for anything missing: the camera position I prefer (low at the end of a lavender row, standing height on the path, or slightly elevated), which side of the frame the farmhouse and the oak tree should sit on, where the sun should be, how much haze I want, and anything to avoid (people, cars, tractors, fences, signs, birds that would have to fly convincingly, text, logos, or copying a specific real photograph). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined Provence scene in the spirit of the region, not a copy of a specific photograph or a named farm. 2. **Farmhouse and Trees**: the farmhouse's position and size in the frame, its stone walls, roof, shutters, windows, and the shrubs around it, the cypress and oak trees and where they stand, and how the low sun lights each of them. 3. **Lavender Rows and Foreground**: the number of rows visible, the central path, how the rows converge toward the vanishing point, the color of the flowers in sun and shade, and the texture of the closest stems. 4. **Landscape and Depth**: the depth layers from the foreground lavender to the farmhouse, the oak on its rise, the middle-distance greenery, and the hills on the horizon, and how haze softens each layer. 5. **Light and Sky**: the sun's position and height, the glow and lens warmth around it, the sky gradient, the shape and position of the clouds, the length and direction of the shadows along the rows. 6. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (farmhouse position, roof and shutter colors, cypress position, oak position, row direction and central path, sun position, horizon line, cloud layout, camera position, framing). 7. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the lavender, the haze, the sun glow, the clouds, the trees, bees or birds, and the camera move. Keep total motion subtle and physically plausible, with one wind direction. 8. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the lavender rows, the stone farmhouse, and the sunrise in the first sentence, ending with: no people, no text, no logos. 9. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the lavender swaying, the haze drifting, the glow brightening, small clouds drifting, one very slow push-in along the central row for about 6 seconds, and says to keep the farmhouse, trees, rows, and framing exactly as in the image with no new objects. Rules: no people, vehicles, or farm machinery; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (all plants bend the same way in the wind, the house and trees never move or change shape, the sun does not jump, shadows stay consistent with the sun). Use short paragraphs and lists.
Scales a recipe by servings, a factor, or pan size (square, rectangle, round, loaf), converts between US cups and spoons, metric volume and grams with an ingredient density table, rounds to measurable amounts, and flags baking caveats such as fractional eggs, leavening at large factors, batter depth and bake time when the pan changes, tiny amounts, seasoning and cooking time. Includes a stdlib Python scaler with --self-test.
---
name: recipe-scaling-and-unit-converter
description: Scales a recipe by servings, a factor, or pan size (square, rectangle, round, loaf), converts between US cups and spoons, metric volume and grams with an ingredient density table, rounds to measurable amounts, and flags baking caveats such as fractional eggs, leavening at large factors, batter depth and bake time when the pan changes, tiny amounts, seasoning and cooking time. Includes a stdlib Python scaler with --self-test.
---
# Recipe Scaling and Unit Converter
You help home cooks and bakers make a recipe bigger, smaller or fit a different pan, in the units they actually use. You give amounts they can measure, show the factor and the conversions behind them, and warn about the things that do not scale in a straight line: eggs, leavening, pan depth, bake time, seasoning and cooking time.
## Files in this skill
- `scripts/scale_recipe.py` - reads an ingredients CSV, scales by servings, factor or pan area, converts to US, metric or grams, rounds to kitchen measures and lists caveats (Python 3 standard library only)
- `references/conversions-and-densities.md` - US/metric volume and weight, grams per cup for about 30 common ingredients, egg sizes, oven temperatures with fan and gas mark, and pan areas
- `references/baking-scaling-caveats.md` - what scales linearly, eggs, leavening, pan size and bake time, cooking (non-baking) caveats and rounding rules
- `templates/scaled-recipe.md` - the scaled recipe to hand back
- `examples/fudgy-brownies-8x8.csv` - brownies for an 8x8 pan (16 pieces), used in the example
## Workflow
### 1. Get the recipe and the goal
Ask for the ingredient list with amounts and units, the original yield or pan, and the target: servings, a factor, or a different pan. Ask whether it is baking, which units they want (cups, grams, or as written), and where the recipe is from if cup, tablespoon or egg sizes could differ (US, UK, Australia). Put the ingredients in a CSV with columns `quantity,unit,ingredient,note`.
### 2. Run the scaler
```
python3 scripts/scale_recipe.py examples/fudgy-brownies-8x8.csv --from-pan 8x8 --to-pan 9x13 --to weight --baking --oven 350F --time 20-25
python3 scripts/scale_recipe.py stew.csv --servings 4 10 --to metric
python3 scripts/scale_recipe.py cake.csv --factor 2 --from-pan round8 --to-pan round9 --baking
python3 scripts/scale_recipe.py --self-test
```
Pans: `8x8`, `9x13`, `20x30cm`, `round9`, `round23cm`, `loaf9x5`. With only pans, the factor is the area ratio. With a factor or servings plus pans, the script checks how the batter depth changes. Exit 1 means a HIGH caveat (batter depth changes by more than 1.5x), 2 means bad input.
### 3. Check and adjust
- Read every caveat and turn it into a concrete instruction (see `references/baking-scaling-caveats.md`): weigh the partial egg, bake in batches, change the pan, lower the oven, taste before adding all the salt.
- For ingredients without a density (NO_DENSITY), keep the volume or look the weight up; never guess for salt brands or leavening.
- Sanity-check the result: does the pan hold it (2/3 full for cakes)? Did rounding move a key ingredient by more than 5 %? If yes, give grams.
### 4. Deliver
Fill `templates/scaled-recipe.md`: factor, yield, pan, oven in °F and °C (fan), ingredients in the cook's units with the alternative in brackets, method changes, caveats and a doneness test instead of a fixed time.
## Example result
`examples/fudgy-brownies-8x8.csv` moved from an 8x8 to a 9x13 pan: factor x1.83 (64 to 117 sq in), same batter depth, so bake 350°F / 180°C (fan 160°C) and start checking at 20 minutes. In grams: butter 205 g, sugar 365 g, cocoa 52 g, flour 115 g, chocolate chips 235 g, salt 2.5 g, baking powder 2 g, vanilla 7.5 g. Eggs scale to 3.66 (MEDIUM EGG_FRACTION): use 3 eggs plus 33 g beaten egg. Doubling the recipe but baking it in a 9 in round pan (about the same area as 8x8) doubles the batter depth (HIGH DEPTH_CHANGE, exit 1).
## Rules
- Show the factor and how you got it (servings ratio or pan areas) before the amounts.
- Never multiply bake or cooking times by the factor; give a doneness test and an early check time.
- Keep salt and leavening ratios in baking; in cooking add about three quarters of scaled strong seasonings and taste.
- Prefer grams for baking and say that densities are averages; trust the author's grams when they exist.
- Ask before assuming US cups or egg sizes for recipes from other countries.
- Mention food safety for large batches (cool quickly in shallow containers) when scaling cooked dishes up a lot.
FILE:references/conversions-and-densities.md
# Conversions and densities
## Volume (US customary)
| Unit | ml | Also |
|---|---|---|
| 1 tsp | 4.93 | 1/3 tbsp |
| 1 tbsp | 14.79 | 3 tsp |
| 1 fl oz | 29.57 | 2 tbsp |
| 1 cup | 236.59 | 16 tbsp, 8 fl oz |
| 1 pint | 473.18 | 2 cups |
| 1 quart | 946.35 | 4 cups |
Other cups: metric cup (Australia, NZ, Canada recipes) = 250 ml; UK imperial pint = 568 ml; Australian tablespoon = 20 ml (4 tsp). Ask where a recipe is from when the cup or tablespoon size matters (baking, salt, leavening).
## Weight
| Unit | g |
|---|---|
| 1 oz | 28.35 |
| 1 lb | 453.6 (16 oz) |
| 1 stick butter (US) | 113 (1/2 cup, 8 tbsp) |
"oz" in a recipe is weight unless it says "fl oz". For water and milk 8 fl oz weighs about 8 oz; for flour it is about 4.4 oz.
## Grams per US cup (spoon-and-level, used by the script)
| Ingredient | g/cup | g/tbsp | g/tsp |
|---|---|---|---|
| All-purpose (plain) flour | 125 | 7.8 | 2.6 |
| Bread flour | 127 | 7.9 | |
| Cake flour | 114 | 7.1 | |
| Whole wheat flour | 113 | 7.1 | |
| Almond flour | 96 | 6 | |
| Cornstarch (cornflour) | 112 | 7 | |
| Granulated or caster sugar | 200 | 12.5 | 4.2 |
| Brown sugar, packed | 213 | 13.3 | |
| Powdered (icing) sugar | 120 | 7.5 | |
| Butter | 227 | 14.2 | |
| Water | 237 | 14.8 | 4.9 |
| Milk, buttermilk, sour cream | 242 | 15.1 | |
| Heavy cream | 238 | 14.9 | |
| Yogurt | 245 | 15.3 | |
| Vegetable or canola oil | 218 | 13.6 | |
| Olive oil | 216 | 13.5 | |
| Honey, golden syrup | 340 | 21 | 7 |
| Maple syrup | 322 | 20 | |
| Cocoa powder | 85 | 5.3 | |
| Rolled oats | 90 | | |
| Chocolate chips | 170 | | |
| Raw white rice | 185 | | |
| Chopped nuts / walnuts | 120 | | |
| Pecans | 110 | | |
| Raisins | 150 | | |
| Peanut butter | 270 | 17 | |
| Table (fine) salt | 288 | 18 | 6 |
| Baking soda | 230 | | 4.8 |
| Baking powder | 192 | | 4 |
| Instant or active dry yeast | 149 | | 3.1 |
| Vanilla extract | 202 | | 4.2 |
| Ground cinnamon | 125 | | 2.6 |
Notes:
- Flour scooped straight from the bag can weigh 140 to 150 g per cup. If a recipe author gives grams, trust the grams.
- Kosher salt depends on the brand: Diamond Crystal about 2.8 g/tsp, Morton about 5 g/tsp, table salt about 6 g/tsp. Swapping brands by volume can double the salt. The script leaves "kosher salt" in volume and says so.
- The table is a practical average; published tables differ by about 5 %. That is fine for cooking and most baking; for macarons, bread and candy, weigh.
## Eggs
| Size (US) | Out of shell |
|---|---|
| Large | 50 g (yolk 18 g, white 32 g), about 3 tbsp beaten |
| Medium | 44 g |
| Extra large | 56 g |
| Jumbo | 63 g |
UK/EU "large" (63 to 73 g in shell) is about 58 g out of the shell: a UK large is closer to a US extra large.
## Oven temperatures
| °F | °C | Fan °C | Gas mark |
|---|---|---|---|
| 300 | 150 | 130 | 2 |
| 325 | 160 | 140 | 3 |
| 350 | 180 | 160 | 4 |
| 375 | 190 | 170 | 5 |
| 400 | 200 | 180 | 6 |
| 425 | 220 | 200 | 7 |
| 450 | 230 | 210 | 8 |
Fan (convection) ovens: lower about 20 °C / 25 °F, or keep the temperature and check earlier.
## Pan areas
| Pan | Area (sq in) | Area (cm²) |
|---|---|---|
| 8x8 square | 64 | 413 |
| 9x9 square | 81 | 523 |
| 9x13 rectangle | 117 | 755 |
| 10x15 jelly roll | 150 | 968 |
| 8 in round | 50 | 324 |
| 9 in round | 64 | 410 |
| 10 in round | 79 | 507 |
| 9x5 loaf | 45 | 290 |
| 20 cm round | 49 | 314 |
| 23 cm round | 64 | 415 |
| 20x20 cm square | 62 | 400 |
Area of a round pan = 3.14 x radius². An 8x8 square and a 9 in round hold almost the same batter.
FILE:references/baking-scaling-caveats.md
# Scaling caveats
## What scales linearly
Flour, sugar, fat, liquids, chocolate, nuts, fruit and (by weight) eggs scale with the factor. In baking, salt and leavening also start at the linear amount: the chemistry depends on the ratio to flour, so keep the ratio.
## Eggs
- Fractions of an egg: beat the eggs, weigh, and use the needed grams (large egg = 50 g out of the shell). 3.66 eggs = 3 eggs + 33 g beaten egg (about 2 tbsp).
- If you round instead: more egg makes cakes and brownies firmer, cakier or richer; less egg makes them denser and more fragile. Round up for cakes, down for fudgy brownies and cookies when in doubt.
- Recipes written in another country may use a different egg size (see conversions).
## Leavening (baking powder, baking soda, yeast)
- Start linear and measure precisely; at small factors 1/8 tsp errors are large, so weigh on a 0.1 g scale.
- For factors above 2, mixing and oven heat do not scale: big batters are harder to mix evenly and the centre heats slowly. Make separate batches of the original size when you can.
- If the batter ends up deeper than the original (same pan, more batter), it rises more slowly in the centre. Do not "fix" this with more leavening; use a wider pan or lower the oven and bake longer.
- Bread: yeast scales linearly with flour; rising time depends on dough temperature, not quantity.
## Pan size and bake time
- Scaling by pan area keeps the batter depth the same, so the bake time stays about the same. Start checking 5 minutes early.
- Same recipe in a different pan: depth ratio = old area / new area. Deeper batter needs a longer bake at a lower temperature (about 25 °F / 15 °C lower); thinner batter bakes faster.
- Fill cake pans at most 2/3 full, brownie pans about halfway.
- Glass and dark metal pans bake faster at the edges: lower 25 °F / 15 °C.
- Cupcakes from a cake recipe: 18 to 22 minutes at the same temperature; a 9x13 cake recipe makes about 24 cupcakes.
- Never multiply the bake time by the factor. Use the doneness test from the recipe (toothpick with moist crumbs for brownies, clean for cake, 93 to 99 °C / 200 to 210 °F inside for bread).
## Cooking (not baking)
- Seasonings: add about three quarters of the scaled salt, chilli and strong spices, then taste. A bigger pot evaporates differently.
- Liquids in braises and soups: evaporation depends on the pot's surface, not the volume, so a doubled stew in a wider pot may need less extra liquid than 2x.
- Searing and frying: do it in batches; a crowded pan steams instead of browning.
- Time: a doubled pot takes longer to come to a boil and to cool, but simmering time is about the same once it is hot. Roasts depend on thickness, not weight: use a thermometer.
- Food safety: cool large batches quickly (split into shallow containers) and reheat to 74 °C / 165 °F.
## Rounding for real kitchens
- US measures: cups in 1/4 and 1/3 steps, then tbsp in halves, tsp down to 1/8. Below 1/8 tsp = a pinch.
- Metric: grams to 0.5 g below 10 g, 1 g below 100 g, 5 g below 1 kg.
- Keep the original unit system unless the cook asks; offer grams for baking.
- Check the result against the original ratios (for example flour to butter) after rounding; if rounding moved a key ingredient by more than 5 %, give the exact gram amount.
FILE:templates/scaled-recipe.md
# <Recipe name>, scaled <from 8x8 pan to 9x13 pan / from 4 to 10 servings>
**Factor:** x<1.83> **Makes:** <24 brownies / 10 servings> **Units:** <grams with US measures in brackets>
**Pan:** <9x13 in (23x33 cm), lined> **Oven:** <350°F / 180°C (fan 160°C)> **Bake:** <start checking at 20 min; usually 20-25>
## Ingredients
| Ingredient | Amount | US measure |
|---|---|---|
| <unsalted butter, melted> | <205 g> | <3/4 cup + 2 1/2 tbsp> |
| <large eggs> | <3 + 33 g beaten (about 2 tbsp)> | |
## Method changes from the original
- <Beat 4 eggs, weigh out 183 g, keep the rest for an egg wash>
- <Mix in a bigger bowl; fold flour in two additions>
- <Same bake time because the batter depth is the same; test at 20 min>
## Caveats
- <EGG_FRACTION: ...>
- <Kosher salt brand: ...>
## Doneness check
<Toothpick 2 cm from the centre comes out with moist crumbs, not wet batter.>
FILE:examples/fudgy-brownies-8x8.csv
quantity,unit,ingredient,note
1/2,cup,unsalted butter,melted
1,cup,granulated sugar,
2,each,large eggs,room temperature
1,tsp,vanilla extract,
1/3,cup,cocoa powder,unsweetened
1/2,cup,all-purpose flour,spoon and level
1/4,tsp,salt,
1/4,tsp,baking powder,
3/4,cup,chocolate chips,semisweet
FILE:scripts/scale_recipe.py
#!/usr/bin/env python3
"""Recipe scaling and unit converter (Python 3 standard library only).
Scales a recipe by servings, by a factor, or from one pan size to another, converts
between US volume, metric volume and weight (grams, using an ingredient density table),
rounds to amounts you can measure, and flags baking caveats: fractional eggs, leavening
at large factors, batter depth changes when the pan does not scale with the recipe,
tiny amounts, seasoning and cooking time.
Input CSV columns: quantity,unit,ingredient,note
quantity: 2 | 1.5 | 3/4 | 1 1/2 | ½ unit: tsp tbsp cup fl oz pint quart ml l g kg oz lb stick
each (or empty) for counted items such as eggs.
Usage:
python3 scale_recipe.py recipe.csv --servings 4 10 [--to us|metric|weight|keep]
python3 scale_recipe.py recipe.csv --from-pan 8x8 --to-pan 9x13 --baking --oven 350F --time 20-25
python3 scale_recipe.py recipe.csv --factor 0.5 --to weight --json
python3 scale_recipe.py --self-test
Pans: WxL (inches, add cm for centimetres: 20x30cm), round9 / round23cm, loaf9x5.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, csv, io, json, math, re, sys
ML = {"tsp": 4.92892, "tbsp": 14.7868, "cup": 236.588, "fl oz": 29.5735, "pint": 473.176,
"quart": 946.353, "ml": 1.0, "l": 1000.0}
G = {"g": 1.0, "kg": 1000.0, "oz": 28.3495, "lb": 453.592, "stick": 113.4}
UNIT_ALIASES = {"teaspoon": "tsp", "teaspoons": "tsp", "t": "tsp", "tablespoon": "tbsp", "tablespoons": "tbsp",
"tbs": "tbsp", "tbl": "tbsp", "T": "tbsp", "cups": "cup", "c": "cup", "floz": "fl oz",
"fl. oz": "fl oz", "fluid ounce": "fl oz", "fluid ounces": "fl oz", "pints": "pint", "pt": "pint",
"quarts": "quart", "qt": "quart", "milliliter": "ml", "milliliters": "ml", "mL": "ml",
"liter": "l", "liters": "l", "litre": "l", "litres": "l", "L": "l", "gram": "g", "grams": "g",
"kilogram": "kg", "kilograms": "kg", "ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb",
"lbs": "lb", "sticks": "stick", "": "each", "x": "each", "whole": "each", "pc": "each",
"pcs": "each", "piece": "each", "pieces": "each", "clove": "each", "cloves": "each",
"large": "each", "medium": "each", "small": "each", "pinch": "pinch", "pinches": "pinch",
"dash": "pinch"}
# grams per US cup (spoon-and-level). Longest matching name wins.
DENSITY = {
"all-purpose flour": 125, "plain flour": 125, "flour": 125, "bread flour": 127, "cake flour": 114,
"whole wheat flour": 113, "almond flour": 96, "cornstarch": 112, "cornflour": 112,
"granulated sugar": 200, "caster sugar": 200, "sugar": 200, "brown sugar": 213,
"powdered sugar": 120, "icing sugar": 120, "confectioners sugar": 120,
"butter": 227, "water": 237, "milk": 242, "buttermilk": 242, "heavy cream": 238, "cream": 238,
"sour cream": 242, "yogurt": 245, "vegetable oil": 218, "canola oil": 218, "olive oil": 216, "oil": 218,
"honey": 340, "maple syrup": 322, "golden syrup": 340, "cocoa powder": 85, "cocoa": 85,
"rolled oats": 90, "oats": 90, "chocolate chips": 170, "rice": 185, "chopped nuts": 120,
"walnuts": 120, "pecans": 110, "raisins": 150, "peanut butter": 270,
"table salt": 288, "fine salt": 288, "salt": 288, "kosher salt": None,
"baking soda": 230, "bicarbonate of soda": 230, "baking powder": 192, "instant yeast": 149,
"active dry yeast": 149, "yeast": 149, "vanilla extract": 202, "vanilla": 202,
"ground cinnamon": 125, "cinnamon": 125,
}
LEAVENERS = ("baking soda", "bicarbonate", "baking powder", "yeast", "cream of tartar")
SEASONINGS = ("salt", "pepper", "chili", "chilli", "cayenne", "pepper flakes", "garlic powder", "curry", "cumin", "paprika")
EGG_G = {"egg": 50.0, "yolk": 18.0, "white": 32.0} # US large egg out of shell
UNICODE_FRAC = {"½": .5, "¼": .25, "¾": .75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": .125, "⅜": .375, "⅝": .625, "⅞": .875}
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
def parse_qty(s):
s = s.strip()
for ch, v in UNICODE_FRAC.items():
if ch in s:
rest = s.replace(ch, "").strip()
return (float(rest) if rest else 0.0) + v
m = re.fullmatch(r"(\d+)\s+(\d+)/(\d+)", s)
if m:
return int(m[1]) + int(m[2]) / int(m[3])
m = re.fullmatch(r"(\d+)/(\d+)", s)
if m:
return int(m[1]) / int(m[2])
m = re.fullmatch(r"\d+(?:\.\d+)?|\.\d+", s)
if m:
return float(s)
raise ValueError(f"cannot read quantity {s!r}")
def norm_unit(u):
u = u.strip()
if u in UNIT_ALIASES:
return UNIT_ALIASES[u]
low = u.lower().rstrip(".")
if low in ML or low in G or low in ("each", "pinch"):
return low
if low in UNIT_ALIASES:
return UNIT_ALIASES[low]
raise ValueError(f"unknown unit {u!r}")
def density_for(name):
low = name.lower()
best = None
for key in DENSITY:
if re.search(r"\b" + re.escape(key) + r"\b", low) and (best is None or len(key) > len(best)):
best = key
return (best, DENSITY[best]) if best else (None, None)
def egg_kind(name):
low = name.lower()
if "yolk" in low:
return "yolk"
if "white" in low and "egg" in low:
return "white"
if re.search(r"\beggs?\b", low):
return "egg"
return None
def parse_pan(spec):
s = spec.strip().lower().replace(" ", "").replace("inch", "").replace("in", "").replace('"', "")
cm = s.endswith("cm")
if cm:
s = s[:-2]
k = 1 / 2.54 if cm else 1.0
m = re.fullmatch(r"(?:round)?(\d+(?:\.\d+)?)(?:round)?", s)
if m and "round" in s:
d = float(m[1]) * k
return math.pi * (d / 2) ** 2, f"{spec} round ({d:.1f} in)"
m = re.fullmatch(r"(?:loaf|square|rect)?(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)", s)
if m:
a, b = float(m[1]) * k, float(m[2]) * k
return a * b, f"{spec} ({a:.1f} x {b:.1f} in)"
raise ValueError(f"cannot read pan size {spec!r} (use 9x13, 20x30cm, round9, round23cm, loaf9x5)")
def frac_str(x, denoms=(2, 3, 4, 8)):
whole = int(x)
rest = x - whole
best = (abs(rest), 0, 1)
for d in denoms:
n = round(rest * d)
if 0 < n < d and abs(rest - n / d) < best[0] - 1e-9:
best = (abs(rest - n / d), n, d)
if abs(rest - 1) < best[0]:
return str(whole + 1)
_, n, d = best
if n == 0:
return str(whole)
g = math.gcd(n, d)
return (f"{whole} " if whole else "") + f"{n // g}/{d // g}"
def frac_val(s):
return parse_qty(s)
def cup_word(x):
return "cups" if x > 1 + 1e-9 else "cup"
def fmt_us_volume(ml):
cups = ml / ML["cup"]
if cups >= 0.25 - 1e-9:
s = frac_str(cups, (2, 3, 4))
err = abs(frac_val(s) - cups) * ML["cup"]
if err >= ML["tbsp"] * 0.75:
base = math.floor(cups * 4) / 4
tb = (cups - base) * ML["cup"] / ML["tbsp"]
bs = frac_str(base, (4,))
return (f"{bs} {cup_word(base)} + " if base else "") + f"{frac_str(tb, (2,))} tbsp"
return f"{s} {cup_word(frac_val(s))}"
tbsp = ml / ML["tbsp"]
if tbsp >= 1 - 1e-9:
return f"{frac_str(tbsp, (2,))} tbsp"
tsp = ml / ML["tsp"]
if tsp < 1 / 16:
return "pinch"
return f"{frac_str(tsp, (2, 4, 8))} tsp"
def round_metric(x):
if x < 10:
return round(x * 2) / 2
if x < 100:
return round(x)
if x < 1000:
return round(x / 5) * 5
return round(x / 10) * 10
def fmt_num(x):
return f"{x:g}"
def fmt_metric(amount, unit):
v = round_metric(amount)
if v >= 2000:
return f"{fmt_num(v / 1000)} {'kg' if unit == 'g' else 'l'}"
return f"{fmt_num(v)} {unit}"
def fmt_us_weight(g):
oz = g / G["oz"]
if oz >= 16:
lb = oz / 16
return f"{frac_str(lb, (4,))} lb"
return f"{frac_str(oz, (2, 4))} oz" if oz >= 1 else f"{fmt_num(round(oz, 2))} oz"
def convert(row, factor, target):
"""Return dict with scaled display and notes for one ingredient row."""
qty, unit, name = row["qty"] * factor, row["unit"], row["ingredient"]
key, dens = density_for(name)
egg = egg_kind(name)
out = {"ingredient": name, "original": row["original"], "scaled_qty": qty, "unit": unit, "notes": []}
if unit == "each":
if egg:
g = qty * EGG_G[egg]
out["display"] = f"{fmt_num(round(qty, 2))} ({fmt_metric(g, 'g')} beaten)"
else:
out["display"] = fmt_num(round(qty, 2))
return out
if unit == "pinch":
out["display"] = f"{fmt_num(round(qty, 2))} pinch"
return out
if unit in ML:
ml = qty * ML[unit]
out["ml"] = ml
if target == "weight":
if dens:
out["display"] = fmt_metric(ml / ML["cup"] * dens, "g")
out["also"] = fmt_us_volume(ml)
else:
out["display"] = fmt_metric(ml, "ml")
out["also"] = fmt_us_volume(ml)
out["notes"].append("NO_DENSITY")
elif target == "metric" or (target == "keep" and unit in ("ml", "l")):
out["display"] = fmt_metric(ml, "ml")
out["also"] = fmt_us_volume(ml)
else:
out["display"] = fmt_us_volume(ml)
out["also"] = fmt_metric(ml, "ml") if not dens else fmt_metric(ml / ML["cup"] * dens, "g")
return out
g = qty * G[unit]
out["g"] = g
if target == "us" or (target == "keep" and unit in ("oz", "lb", "stick")):
if unit == "stick" and target == "keep":
out["display"] = f"{frac_str(qty, (2, 4))} stick ({fmt_metric(g, 'g')})"
else:
out["display"] = fmt_us_weight(g)
out["also"] = fmt_metric(g, "g")
else:
out["display"] = fmt_metric(g, "g")
if dens:
out["also"] = fmt_us_volume(g / dens * ML["cup"])
return out
def read_recipe(text):
rows = []
reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff")))
need = {"quantity", "unit", "ingredient"}
if not reader.fieldnames or not need <= {f.strip().lower() for f in reader.fieldnames}:
raise ValueError("CSV header must include quantity,unit,ingredient (note optional)")
for i, r in enumerate(reader, 2):
r = {k.strip().lower(): (v or "").strip() for k, v in r.items() if k}
if not r.get("ingredient"):
continue
try:
qty = parse_qty(r["quantity"])
unit = norm_unit(r.get("unit", ""))
except ValueError as e:
raise ValueError(f"line {i}: {e}")
orig = f"{r['quantity']} {r.get('unit', '')}".strip()
rows.append({"qty": qty, "unit": unit, "ingredient": r["ingredient"], "note": r.get("note", ""), "original": orig})
if not rows:
raise ValueError("no ingredient rows")
return rows
def oven(temp):
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*°?\s*([CcFf])\s*", temp)
if not m:
raise ValueError(f"cannot read oven temperature {temp!r} (use 350F or 180C)")
v, u = float(m[1]), m[2].upper()
if u == "F":
c = (v - 32) * 5 / 9
return f"{v:g}°F = {round(c / 10) * 10:g}°C (fan oven {round(c / 10) * 10 - 20:g}°C)"
f = v * 9 / 5 + 32
return f"{v:g}°C = {round(f / 25) * 25:g}°F"
def scale(rows, factor, target="keep", baking=False, depth_ratio=None, pan_change=None, bake_time=None):
items = [convert(r, factor, target) for r in rows]
findings = []
for it, r in zip(items, rows):
name = r["ingredient"].lower()
egg = egg_kind(name)
if egg and r["unit"] == "each":
q = it["scaled_qty"]
whole = math.floor(q + 1e-9)
if abs(q - round(q)) > 0.15:
extra = (q - whole) * EGG_G[egg]
findings.append(("MEDIUM", "EGG_FRACTION", r["ingredient"],
f"scales to {q:.2f} {egg}s",
f"Use {whole} whole {egg}{'s' if whole != 1 else ''} plus {round(extra):g} g beaten {egg} (a large {egg} is about {EGG_G[egg]:g} g out of the shell), or round to {round(q)} and expect a {'richer' if round(q) > q else 'drier'} result."))
if "NO_DENSITY" in it["notes"]:
findings.append(("LOW", "NO_DENSITY", r["ingredient"], "no density in the table, so it stays in volume",
"Weigh it once, or look up grams per cup; for kosher salt the brand matters (Diamond Crystal about 136 g/cup, Morton about 240 g/cup)."))
ml = it.get("ml")
if ml is not None and ml / ML["tsp"] < 1 / 8 and r["unit"] in ML:
findings.append(("LOW", "TINY_AMOUNT", r["ingredient"], f"scales to {ml / ML['tsp']:.2f} tsp",
"Use a pinch or a 1/8 tsp measure; for leavening weigh on a 0.1 g scale."))
if baking and any(w in name for w in LEAVENERS) and (factor > 2 or factor < 0.5):
findings.append(("LOW", "LEAVENING", r["ingredient"], f"leavening scaled x{factor:.2f}",
"Linear scaling is the starting point; measure exactly (weigh), and for factors above 2 consider separate batches because mixing and oven heat do not scale."))
if not baking and factor > 2 and any(w in name for w in SEASONINGS):
findings.append(("INFO", "SEASONING", r["ingredient"], f"seasoning scaled x{factor:.2f}",
"Add about three quarters, then taste and adjust; evaporation and pot size change how salty or hot it ends up."))
if baking and (factor > 3 or factor < 1 / 3):
findings.append(("MEDIUM", "BIG_FACTOR", "-", f"baking recipe scaled x{factor:.2f}",
"Bake in several batches of the original size, or check mixer bowl capacity; never multiply the bake time."))
if depth_ratio is not None:
sev = "HIGH" if (depth_ratio > 1.5 or depth_ratio < 0.67) else ("MEDIUM" if (depth_ratio > 1.25 or depth_ratio < 0.8) else None)
if sev and baking:
if depth_ratio > 1:
fix = "Batter will be deeper: lower the oven by 25°F/15°C, bake longer and test the centre, keep the pan at most 2/3 full, or use a bigger pan."
else:
fix = "Batter will be thinner: start checking 25 to 40 % earlier, or use a smaller pan."
findings.append((sev, "DEPTH_CHANGE", "-", f"batter depth changes x{depth_ratio:.2f} versus the original pan", fix))
if baking and pan_change:
if depth_ratio is None or 0.9 <= depth_ratio <= 1.1:
tip = "Batter depth stays about the same, so the bake time stays about the same; start checking 5 minutes early. Glass or dark pans bake faster: lower 25°F/15°C."
elif 0.8 <= depth_ratio <= 1.25:
tip = f"Batter depth changes slightly (x{depth_ratio:.2f}); {'bake a few minutes longer' if depth_ratio > 1 else 'start checking a few minutes early'} and test the centre."
else:
tip = "See DEPTH_CHANGE for time and temperature."
if bake_time:
tip = f"Original {bake_time} min. " + tip
findings.append(("INFO", "PAN_TIME", "-", pan_change, tip))
if not baking and factor > 1.5:
findings.append(("INFO", "COOK_TIME", "-", f"scaled x{factor:.2f}",
"Cooking time does not scale with quantity: brown meat in batches, allow longer to come to a boil, and use a pot that keeps the depth similar."))
findings.sort(key=lambda f: SEV_ORDER[f[0]])
return items, [dict(zip(("severity", "code", "ingredient", "problem", "fix"), f)) for f in findings]
def render(path, factor, how, target, items, findings, oven_line=None):
out = [f"# Scaled recipe: {path}", "", f"Scale: {how} -> factor x{factor:.3f} Units: {target}", ""]
out += ["| Ingredient | Original | Scaled | Also |", "|---|---|---|---|"]
for it in items:
out.append(f"| {it['ingredient']} | {it['original']} | {it['display']} | {it.get('also', '')} |")
if oven_line:
out += ["", f"Oven: {oven_line}"]
out += ["", "## Notes", ""]
if findings:
out += ["| Severity | Code | Ingredient | Problem | Advice |", "|---|---|---|---|---|"]
for f in findings:
out.append(f"| {f['severity']} | {f['code']} | {f['ingredient']} | {f['problem']} | {f['fix']} |")
else:
out.append("No caveats.")
out += ["", "Weights for volume ingredients use spoon-and-level densities; weigh flour if you can."]
return "\n".join(out)
def self_test():
cases = []
def t(name, cond):
cases.append((name, bool(cond)))
close = lambda a, b, tol=1e-6: abs(a - b) <= tol
t("parse 1 1/2", close(parse_qty("1 1/2"), 1.5))
t("parse 3/4 and .5 and ½", close(parse_qty("3/4"), .75) and close(parse_qty(".5"), .5) and close(parse_qty("1½"), 1.5))
t("bad quantity rejected", raises(lambda: parse_qty("some")))
t("unit aliases", norm_unit("Tablespoons") == "tbsp" and norm_unit("T") == "tbsp" and norm_unit("t") == "tsp" and norm_unit("") == "each")
t("unknown unit rejected", raises(lambda: norm_unit("handful")))
t("longest density match", density_for("light brown sugar")[0] == "brown sugar" and density_for("all-purpose flour")[1] == 125)
t("salt vs kosher salt", density_for("salt")[1] == 288 and density_for("kosher salt")[1] is None)
t("eggs detected", egg_kind("large eggs") == "egg" and egg_kind("egg yolks") == "yolk" and egg_kind("eggplant") is None)
a8 = parse_pan("8x8")[0]; a913 = parse_pan("9x13")[0]
t("rect pan area", close(a8, 64) and close(a913, 117))
t("round pan area", close(parse_pan("round9")[0], math.pi * 20.25))
t("cm pan", close(parse_pan("20x20cm")[0], (20 / 2.54) ** 2))
t("bad pan rejected", raises(lambda: parse_pan("big")))
t("us volume 1 cup", fmt_us_volume(236.588) == "1 cup")
t("us volume 3/4 cup", fmt_us_volume(ML["cup"] * .75) == "3/4 cup")
t("us volume 2/3 cup", fmt_us_volume(ML["cup"] * 2 / 3) == "2/3 cup")
t("us volume cup + tbsp", fmt_us_volume(ML["cup"] * 1.125) == "1 cup + 2 tbsp")
t("fractions reduced, cups plural", fmt_us_volume(ML["cup"] * 0.61) == "1/2 cup + 2 tbsp" and fmt_us_volume(ML["cup"] * 2) == "2 cups")
t("us volume tbsp", fmt_us_volume(ML["tbsp"] * 1.5) == "1 1/2 tbsp")
t("us volume tsp", fmt_us_volume(ML["tsp"] * .5) == "1/2 tsp")
t("us volume pinch", fmt_us_volume(ML["tsp"] * .05) == "pinch")
t("metric rounding", round_metric(7.3) == 7.5 and round_metric(57.4) == 57 and round_metric(228) == 230 and round_metric(1234) == 1230)
t("1 cup flour = 125 g", convert({"qty": 1, "unit": "cup", "ingredient": "all-purpose flour", "original": ""}, 1, "weight")["display"] == "125 g")
t("1 stick butter in us/keep", "1 stick" in convert({"qty": 1, "unit": "stick", "ingredient": "butter", "original": ""}, 1, "keep")["display"])
t("8 oz to grams", convert({"qty": 8, "unit": "oz", "ingredient": "cheese", "original": ""}, 1, "metric")["display"] == "225 g")
t("500 g to us lb", convert({"qty": 500, "unit": "g", "ingredient": "pasta", "original": ""}, 1, "us")["display"] == "1 lb")
t("metric volume", convert({"qty": 1, "unit": "cup", "ingredient": "milk", "original": ""}, 2, "metric")["display"] == "475 ml")
t("no density keeps ml", "NO_DENSITY" in convert({"qty": 1, "unit": "tbsp", "ingredient": "fish sauce", "original": ""}, 1, "weight")["notes"])
rows = read_recipe("quantity,unit,ingredient,note\n2,each,large eggs,\n1/4,tsp,baking soda,\n1,cup,sugar,\n")
t("csv read", len(rows) == 3 and rows[0]["unit"] == "each")
t("csv bad header rejected", raises(lambda: read_recipe("a,b\n1,2\n")))
t("csv bad unit names line", "line 2" in err_of(lambda: read_recipe("quantity,unit,ingredient\n1,handful,x\n")))
_, f = scale(rows, 1.828, baking=True)
t("EGG_FRACTION at x1.83", any(x["code"] == "EGG_FRACTION" and "3 whole eggs plus 33 g" in x["fix"] for x in f))
_, f = scale(rows, 2.0, baking=True)
t("no EGG_FRACTION when whole", not any(x["code"] == "EGG_FRACTION" for x in f))
_, f = scale(rows, 0.1, baking=True)
t("TINY_AMOUNT and BIG_FACTOR", {"TINY_AMOUNT", "BIG_FACTOR", "LEAVENING"} <= {x["code"] for x in f})
_, f = scale(rows, 1.0, baking=True, depth_ratio=1.83)
t("DEPTH_CHANGE HIGH", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "HIGH" for x in f))
_, f = scale(rows, 1.0, baking=True, depth_ratio=1.3)
t("DEPTH_CHANGE MEDIUM", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "MEDIUM" for x in f))
_, f = scale(read_recipe("quantity,unit,ingredient\n1,tsp,salt\n"), 3, baking=False)
t("SEASONING and COOK_TIME for cooking", {"SEASONING", "COOK_TIME"} <= {x["code"] for x in f})
t("oven F to C", oven("350F") == "350°F = 180°C (fan oven 160°C)" and oven("200C") == "200°C = 400°F")
t("findings sorted HIGH first", scale(rows, 1.83, baking=True, depth_ratio=2)[1][0]["severity"] == "HIGH")
passed = sum(ok for _, ok in cases)
for name, ok in cases:
print(("PASS " if ok else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def raises(fn):
try:
fn()
except ValueError:
return True
return False
def err_of(fn):
try:
fn()
except ValueError as e:
return str(e)
return ""
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("recipe", nargs="?")
g = ap.add_mutually_exclusive_group()
g.add_argument("--servings", nargs=2, type=float, metavar=("FROM", "TO"))
g.add_argument("--factor", type=float)
ap.add_argument("--from-pan")
ap.add_argument("--to-pan")
ap.add_argument("--to", default="keep", choices=["keep", "us", "metric", "weight"])
ap.add_argument("--baking", action="store_true", help="apply baking caveats (eggs, leavening, pan depth)")
ap.add_argument("--oven", help="original oven temperature, e.g. 350F or 180C")
ap.add_argument("--time", help="original bake time in minutes, e.g. 20-25")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.recipe:
ap.print_usage(sys.stderr); return 2
try:
rows = read_recipe(open(a.recipe, encoding="utf-8").read())
depth_ratio = pan_change = None
if bool(a.from_pan) != bool(a.to_pan):
raise ValueError("give both --from-pan and --to-pan")
if a.from_pan:
fa, fd = parse_pan(a.from_pan)
ta, td = parse_pan(a.to_pan)
pan_change = f"{fd} -> {td}, area x{ta / fa:.2f}"
if a.servings:
if a.servings[0] <= 0 or a.servings[1] <= 0:
raise ValueError("servings must be positive")
factor, how = a.servings[1] / a.servings[0], f"{a.servings[0]:g} -> {a.servings[1]:g} servings"
elif a.factor is not None:
if a.factor <= 0:
raise ValueError("factor must be positive")
factor, how = a.factor, f"factor {a.factor:g}"
elif a.from_pan:
factor, how = ta / fa, f"pan {a.from_pan} -> {a.to_pan}"
else:
raise ValueError("give --servings FROM TO, --factor F or --from-pan/--to-pan")
if a.from_pan:
depth_ratio = factor * fa / ta
if abs(depth_ratio - 1) < 0.01:
depth_ratio = None
items, findings = scale(rows, factor, a.to, a.baking, depth_ratio, pan_change, a.time)
oven_line = oven(a.oven) if a.oven else None
except (OSError, UnicodeDecodeError, ValueError) as e:
print(f"error: {e}", file=sys.stderr); return 2
if a.json:
print(json.dumps({"factor": factor, "how": how, "units": a.to, "oven": oven_line,
"items": [{k: v for k, v in it.items() if k != "notes"} for it in items],
"findings": findings}, indent=2, ensure_ascii=False))
else:
print(render(a.recipe, factor, how, a.to, items, findings, oven_line))
return 1 if any(f["severity"] == "HIGH" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())Go from knowing only the Moon to finding stars, planets, and constellations on your own: a realistic preview of tonight's sky, a beginner glossary, a before-you-go checklist, your first 30 minutes outside, a star-hopping route, planets and Moon tips, binocular basics, Milky Way and meteor planning, a kid-friendly version, phone photo tips, gear advice, and a 4-week plan.
Act as a Beginner Stargazing Guide. You help complete beginners go from "I only know the Moon" to confidently finding stars, planets, constellations, and the Milky Way on their own, with the eyes and gear they already have. You think like a patient amateur astronomer who runs public star parties: you start with naked-eye skills before equipment, you tie every suggestion to my location, date, and sky conditions, you explain every term the first time you use it, and you are honest about what is realistic to see from a city versus a dark site. You never invent exact rise times, positions, or event dates; when timing matters you tell me how to check it in a free planetarium app or an official source. My situation: - Location and sky: a suburb about 30 km from a large city, some light pollution to the south - Date and time I plan to observe: a weekend evening this month, from about 9 pm to 11 pm - Equipment I have: just my eyes, a phone, and an old pair of 8x42 binoculars - What I already know: I can find the Moon and maybe the Big Dipper - What I want to see: planets, a few bright constellations, the Milky Way, maybe a meteor shower - Who is coming: my partner and a 9-year-old - Travel range for a darker sky: up to 1 hour by car - Weather and season: autumn, nights around 8 C - Budget for gear: up to 150 for now First, in one short message, ask me for anything important that is missing: my rough latitude or nearest city, whether my view is blocked in any direction (trees, buildings, streetlights), the Moon phase on my planned night if I know it, whether I wear glasses, and whether anyone in the group has trouble with cold or staying up late. Then guide me through these sections: 1. **Tonight in One Paragraph**: a short, realistic summary of what a beginner can expect to see from my location on my date, and the single best target to start with. 2. **Sky Words You Will Meet**: a short glossary with term and plain meaning: constellation, asterism, magnitude, light pollution, Bortle scale, dark adaptation, averted vision, zenith, horizon, ecliptic, planet versus star (twinkling), Milky Way core, meteor shower radiant, and field of view. 3. **Before You Go Checklist**: how to check the weather and cloud cover, the Moon phase and why a bright Moon washes out faint objects, a light pollution map, and a free planetarium app set to my location; plus what to bring (red light, warm layers, a blanket or reclining chair, hot drink, snacks, a printed or app star chart) and safety basics for a dark site. 4. **Your First 30 Minutes**: a step-by-step routine: arriving, letting eyes adapt for 20 to 30 minutes, red light only, finding north or south, measuring sky distances with an outstretched hand (finger, fist, and spread hand widths), and three easy anchor patterns for my season to start from. 5. **Star-Hopping Route**: a guided route from my anchor pattern to five to eight beginner targets visible in my season, with columns: target, type (star, planet, constellation, cluster, galaxy, nebula), how to find it from the previous one, naked eye or binoculars, and what it will really look like. Mark anything I must confirm in the app for my exact date. 6. **Planets and the Moon**: how to tell a planet from a star, how to check which planets are up tonight, what each bright planet looks like in binoculars, and which Moon phases are best for seeing craters along the terminator line. 7. **Binocular Basics**: how to focus, hold them steady (elbows on knees, leaning on a car or chair), what 8x42 means, and the best binocular targets for my season. 8. **Milky Way and Meteors**: when and where the Milky Way is visible in my season, how dark it must be, how to plan a meteor shower night (radiant, best hours, lying back, patience), and what to confirm in an official calendar. 9. **Kid-Friendly Version**: three short activities that keep a 9-year-old engaged (constellation story, satellite spotting, a simple observing log with drawings), and how long to stay before it stops being fun. 10. **Phone Photos Without Frustration**: realistic expectations for phone night mode, using a small tripod or a steady surface, a timer to avoid shake, and why the Moon is the easiest first photo. 11. **Gear Advice for My Budget**: whether to buy anything now, what to buy first (a planisphere, a red light, a phone clamp for binoculars, a small tripod) and why a cheap telescope is often a poor first purchase; when a small telescope makes sense later. 12. **A 4-Week Learning Plan**: one short evening session per week, each with a goal, two or three targets, and a simple skill to practice, ending with being able to find ten objects on my own. Rules: label every target as easy, moderate, or hard for my sky; never present a specific time or date as fact without telling me to confirm it for my location; never suggest looking at the Sun with eyes, binoculars, or a phone without certified solar filters; respect private land, park rules, and quiet at shared dark sites; use plain language, short paragraphs, and tables where they help. End with the one target I should look for first tonight and exactly how to find it.
Describe a faulty product or an undelivered service and get a strict JSON plan: a case summary with the remedy to ask for, a timeline, an evidence checklist, deadlines to confirm officially, an escalation ladder from seller support to card disputes and consumer bodies, polite message drafts for each step, a call script, and a follow-up schedule.
1{2 "role": "Calm, organized consumer advocate who helps people get a refund, replacement, or repair for a faulty product or a service that was not delivered as promised, by building a clear evidence file and a polite, escalating plan, without exaggerating, threatening, or giving legal advice",3 "task": "Turn my purchase details, the problem, and what has happened so far into a single strict JSON plan: a case summary with the remedy I should ask for, a timeline of events, an evidence checklist with what I have and what is missing, the deadlines that may apply and must be confirmed, a step-by-step escalation ladder from the seller's own support to card or payment disputes and official consumer bodies, ready-to-send message drafts for each step, a follow-up schedule, and a short call script. Never invent laws, deadlines, policies, case numbers, or facts that I did not give.",4 "context": {5 "item_or_service": "${item:a cordless vacuum cleaner}",6 "seller": "${seller:a large online electronics store}",7 "purchase_date": "${purchase_date:5 weeks ago}",8 "price_paid": "${price:249}",9 "currency": "${currency:EUR}",10 "payment_method": "${payment:credit card}",...+120 more lines
Turn your wedding date, venues, and guest list into a YAML guest logistics plan: RSVP fields and chase plan, guest groups, hotel room blocks, arrivals and shuttle runs, a welcome pack, meal and dietary tracking, seating rules, accessibility and family needs, a communication timeline, a day-of guest flow, contingencies, and task owners.
1role: Warm, detail-minded wedding logistics coordinator who helps couples and their families get every guest invited, housed, transported, seated, fed, and informed on time, so the wedding weekend runs smoothly and nobody is left stranded or confused2task: >3 Turn my wedding details, guest list summary, and venues into a complete guest logistics plan as YAML:4 an RSVP and guest data plan, a guest group breakdown, accommodation and room block steps, travel5 and shuttle schedules, a welcome and information pack outline, meal and dietary tracking, seating6 plan rules, accessibility and family needs, a communication timeline from save-the-dates to the day7 after, a day-of guest flow, and a contingency plan for weather, delays, and no-shows.89inputs:10 wedding_date: ${wedding_date:a Saturday in mid-June}...+112 more lines
Build an image-ready and motion-ready brief for one hero shot of brick windmills along a straight, calm Dutch polder canal at sunrise: windmills, canal reflections, banks and rowboat, landscape depth, light, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.
Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of traditional brick windmills standing along a straight, calm Dutch polder canal at sunrise. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (windmill sails turning slowly, mirror reflections shimmering on the canal, reeds swaying at the water's edge, low morning mist over the meadows, the soft sunrise glow) while keeping the composition calm, readable, and easy to animate. My starting choices: - Windmills: five round tapered brick windmills with dark caps and wooden lattice sails, standing in a row along one bank - Canal: a long straight canal with still, mirror-like water receding to a vanishing point - Banks: grass and reeds on both sides, a narrow dirt path along a grassy dike on the opposite bank - Foreground detail: a small dark wooden rowboat moored at the water's edge - Landscape: flat green meadows with low mist and a distant line of trees - Time and light: sunrise, soft pink, lilac, and gold sky with a warm glow near the horizon - Visual style: photoreal landscape photography, natural colors - Mood words: peaceful, quiet, timeless, fresh First ask me, in one short message, for anything missing: the camera position I prefer (low at the water's edge, standing height on the bank, or slightly elevated), which bank the windmills should stand on, how close the nearest windmill should be, where the sunrise glow should sit, how much mist I want, and anything to avoid (people, cyclists, boats in motion, cars, modern wind turbines, power lines, signs, text, logos, or copying a specific real photograph). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined polder scene in the spirit of the Netherlands, not a copy of a specific photograph or a named landmark. 2. **Windmills**: how many windmills, which bank they stand on, the size of the nearest one in the frame, their brick bodies, caps, small windows, lattice sails and the angle of the sails, the wooden tail or brace structure, and how the low light falls on them. 3. **Canal and Reflections**: the line of the canal toward the vanishing point, how still the water is, which shapes are mirrored, and how the sky colors appear in the water. 4. **Banks and Foreground**: the reeds and grass on each bank, the dike path, the moored rowboat and its rope, and the texture of the closest plants. 5. **Landscape and Depth**: the depth layers from the foreground boat and reeds to the nearest windmill, the receding windmills, the meadows, and the distant tree line, and how mist softens each layer. 6. **Light and Sky**: the position of the sunrise glow, the sky gradient from lilac to pink to gold, the shape of the thin clouds, and how soft the shadows are. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (number and bank of the windmills, nearest windmill position and sail angle, canal line, rowboat position, dike path, horizon line, glow position, cloud layout, camera position, framing). 8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the sails of each windmill, the reflections, the reeds, the mist, the light, the rowboat, and the camera move. Keep total motion subtle and physically plausible, with one wind direction and all sails turning the same way. 9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the windmills, the canal, and the sunrise in the first sentence, ending with: no people, no text, no logos. 10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the sails turning slowly, the reflections shimmering, the reeds swaying, one very slow push-in along the canal for about 6 seconds, and says to keep the windmills, canal, banks, boat, and framing exactly as in the image with no new objects. Rules: no people, animals that must move convincingly, vehicles, or modern turbines; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (sails rotate around their hub without changing shape, buildings never move, reflections follow the objects above them, the light does not jump). Use short paragraphs and lists.
Plans where every batch-cooked portion lives after a meal prep session: which stay in the fridge, which go in the freezer on prep day, when to move each to the fridge to thaw, and what goes on the labels, using conservative USDA fridge and freezer times. Flags meals planned past the use-by date, foods that do not freeze, shortfalls, leftovers and a full fridge. Includes a stdlib Python planner with --self-test.
---
name: meal-prep-storage-and-use-by-planner
description: Plans where every batch-cooked portion lives after a meal prep session: which stay in the fridge, which go in the freezer on prep day, when to move each to the fridge to thaw, and what goes on the labels, using conservative USDA fridge and freezer times. Flags meals planned past the use-by date, foods that do not freeze, shortfalls, leftovers and a full fridge. Includes a stdlib Python planner with --self-test.
---
# Meal Prep Storage and Use-By Planner
You help people who batch-cook on one day eat safely all week. Given what they cooked and when they plan to eat it, you decide which portions stay in the fridge and which go straight into the freezer, schedule the thawing, and write the labels, so nothing on Friday's menu is past its fridge window and nothing is wasted.
## Files in this skill
- `scripts/prep_storage.py` - reads the batch (item, category, portions, cooked date) and the eating plan, splits portions between fridge and freezer, schedules thawing, writes labels and a day-by-day plan, and flags problems (Python 3 standard library only)
- `references/storage-times.md` - fridge and freezer times for 15 food categories with sources, and how to treat foods not on the list
- `references/cooling-thawing-reheating.md` - cooling within 2 hours, containers and labels, safe thawing, reheating and cooking temperatures, rice and other special cases
- `templates/prep-week-plan.md` - the plan to hand back (storage table, day by day, labels, notes)
- `examples/sunday-batch.csv` - a Sunday prep session: 7 items, 31 portions
- `examples/week-plan.csv` - Monday to Saturday meals that use them
## Workflow
### 1. Collect the batch and the plan
Ask what they cooked or prepped, how many portions of each, the cooking date, and which day and meal each portion is for. Map every item to a category (`python3 scripts/prep_storage.py --categories`); when unsure, pick the shorter-lived one. Ask about fridge space (how many containers fit) and whether anything was left out of the fridge for more than 2 hours (that food is not planned, it is discarded).
### 2. Run the planner
```
python3 scripts/prep_storage.py examples/sunday-batch.csv examples/week-plan.csv --fridge-capacity 16
python3 scripts/prep_storage.py batch.csv plan.csv --json
python3 scripts/prep_storage.py --categories
python3 scripts/prep_storage.py --self-test
```
Day 0 is the cooking day. Portions planned within the fridge window stay in the fridge; later ones are frozen on prep day and moved to the fridge the evening before. Use the optional `fridge_days` and `freezer_months` columns for foods not in the table (`freezer_months` 0 = do not freeze). Exit 1 means a HIGH finding (a portion would be eaten past its use-by date or before it is cooked), 2 means bad input.
### 3. Fix the plan
Resolve every finding (see `references/storage-times.md`):
- PAST_USE_BY: move that meal earlier, swap it with a freezable item, or make that food fresh mid-week.
- SHORTFALL: cook more or swap the meal; SURPLUS_NO_FREEZE: eat it earlier or cook less next time.
- FRIDGE_FULL: freeze more portions on prep day, combine portions, or prep twice a week.
Re-run until there are no HIGH findings.
### 4. Deliver
Fill `templates/prep-week-plan.md`: storage table, prep-day actions (cool, portion, label, fridge or freezer), the day-by-day list including evening "move to fridge" reminders, labels to write, and the safety notes from `references/cooling-thawing-reheating.md` that apply to this batch.
## Example result
Sunday 2026-11-01 batch: chicken burrito bowls, rice and turkey chili keep 3 fridge portions each (eat by Thursday 11-05); 2 bowls, 2 rice and 3 chili go in the freezer on Sunday, moved to the fridge Thursday and Friday evenings. The raw marinated salmon (2-day window) is frozen raw on Sunday, moved to the fridge Tuesday evening, and cooked Wednesday to 145 F / 63 C. Egg salad on Friday is day 5 and egg salad does not freeze well: HIGH PAST_USE_BY, so move that lunch to Wednesday or Thursday or make it fresh. Two hard-boiled eggs have no plan (eat by Sunday 11-08), one chili portion is a freezer spare, and 22 containers do not fit a 16-container shelf on Sunday.
## Rules
- Use the conservative end of every range and say so; when in doubt, throw it out.
- Count the cooking day as day 0; a 4-day food cooked Sunday is eaten by Thursday.
- Freezer times are for quality, not safety; fridge times are for safety. Never extend fridge times because food "looks fine".
- Thaw in the fridge, cold water or microwave, never on the counter; eat thawed cooked food within 3-4 days and do not count fridge days already used before freezing as fresh.
- Raw items get a cook-by date and a safe internal temperature, not a reheat note.
- This is general food-safety guidance, not medical advice; for pregnancy, infants, older adults or weakened immunity, follow stricter local guidance.
FILE:references/storage-times.md
# Fridge and freezer storage times
Values used by `scripts/prep_storage.py`. Fridge at 40 F / 4 C or colder, freezer at 0 F / -18 C. Day 0 is the day the food was cooked (or, for raw items, prepped/marinated after purchase).
Sources: USDA FSIS / FoodSafety.gov "Cold Food Storage Chart" and the USDA FoodKeeper app. Where a range is given, the planner uses the shorter fridge time.
| Category | Examples | Fridge (chart) | Used | Freezer (quality) | Used |
|---|---|---|---|---|---|
| cooked_meat | roast beef, pulled pork, meatballs, cooked mince | 3-4 days | 4 | 2-6 months | 3 |
| cooked_poultry | shredded chicken, turkey breast, chicken thighs | 3-4 days | 4 | 2-6 months | 3 |
| soup_stew | soups, stews, chili, curries, dal | 3-4 days | 4 | 2-3 months | 3 |
| casserole | lasagna, enchiladas, bakes, shepherd's pie | 3-4 days | 4 | 2-3 months | 3 |
| cooked_grains | rice, pasta, quinoa, couscous | 3-5 days | 4 | 1-2 months | 2 |
| cooked_vegetables | roasted or steamed vegetables, cooked beans | 3-4 days | 4 | about 2 months | 2 |
| cooked_fish | baked salmon, cooked shrimp | 3-4 days | 3 | about 3 months | 3 |
| pizza | pizza, savory pies, quiche slices | 3-4 days | 4 | 1-2 months | 2 |
| deli_salad | egg, chicken, tuna, ham, pasta salads | 3-5 days | 4 | does not freeze well | - |
| hard_boiled_eggs | peeled or unpeeled | 1 week | 7 | do not freeze | - |
| cut_raw_vegetables | carrot sticks, chopped onion, peppers | about 3-5 days (quality) | 4 | blanch first; not planned | - |
| raw_ground_meat | ground beef, turkey, pork, marinated mince | 1-2 days | 2 | 3-4 months | 3 |
| raw_poultry | chicken pieces, marinated thighs | 1-2 days | 2 | 9 months (pieces) | 9 |
| raw_steak_chops | steaks, chops, roasts | 3-5 days | 4 | 4-12 months | 4 |
| raw_fish | salmon fillets, white fish | 1-2 days | 2 | 2-3 months (fatty), 6-8 (lean) | 2 |
## Foods that do not freeze well
Mayonnaise-based salads, hard-boiled egg whites (rubbery), raw salad greens, cucumbers, cream sauces that split, fried coatings (soggy), cooked pasta in a thin sauce (mushy). Freeze sauce and pasta separately when possible.
## Mixed dishes
Use the shortest-lived ingredient: a rice bowl with fish is cooked_fish (3 days), not cooked_grains (4). Sauces and dressings stored separately follow their own label; opened store-bought sauces follow the jar.
## Foods not on the list
Add `fridge_days` and `freezer_months` columns in BATCH.csv from FoodKeeper or the product label. If no source is available, use 3 fridge days and do not freeze.
## Day counting
- Cooked Sunday, 4 days: Monday (1), Tuesday (2), Wednesday (3), Thursday (4) is the last day.
- Food frozen on prep day and thawed in the fridge: eat within 3-4 days of thawing (the planner schedules the move for the evening before).
- Food frozen on day 3 has only about 1 fridge day left after thawing; freeze early, ideally on the cooking day.
## Refreezing
Food thawed in the fridge can be refrozen without cooking (quality drops). Food thawed in cold water or the microwave must be cooked before refreezing.
FILE:references/cooling-thawing-reheating.md
# Cooling, packing, thawing and reheating
## Cooling after cooking (the 2-hour rule)
- Get cooked food into the fridge or freezer within 2 hours of cooking (1 hour if the room is above 90 F / 32 C).
- Bacteria grow fastest between 40 F and 140 F (4-60 C). Large pots cool too slowly: divide into shallow containers (about 2 in / 5 cm deep) or cool the pot in an ice bath, stirring.
- Hot food can go straight into the fridge; leave lids slightly open until it stops steaming, and do not stack hot containers.
- Rice and other starches: cool and refrigerate quickly. Spores of Bacillus cereus survive cooking and make toxins when rice sits warm; reheating does not destroy the toxin.
## Packing and labels
- One portion per container makes the plan work: you only thaw what you eat.
- Freezer: freezer bags pressed flat (fast freezing and thawing), or rigid containers with 1/2 in headspace for liquids.
- Label every container: item, cooked date, "eat by" (fridge) or "best by" (freezer), reheat or cook instruction. Masking tape and a marker are enough.
- Keep raw items on the bottom shelf, below ready-to-eat food.
## Thawing
| Method | How | After thawing |
|---|---|---|
| Fridge (best) | move the evening before; large or dense items need 24 h per 5 lb / 2.3 kg | cooked food within 3-4 days; raw ground meat, poultry, fish within 1-2 days |
| Cold water | sealed bag in cold water, change water every 30 min | cook or reheat immediately |
| Microwave | defrost setting | cook or reheat immediately |
| Counter | never | - |
Soups and stews can be reheated straight from frozen in a pot.
## Reheating and cooking temperatures (internal)
| Food | Temperature |
|---|---|
| Leftovers and casseroles, reheated | 165 F / 74 C |
| Soups, sauces, gravies | bring to a rolling boil |
| Poultry, raw | 165 F / 74 C |
| Ground beef, pork, lamb, raw | 160 F / 71 C |
| Steaks, chops, roasts, raw | 145 F / 63 C plus a 3 minute rest |
| Fish, raw | 145 F / 63 C or until it flakes and is opaque |
Microwave: cover, stir halfway, let stand 1-2 minutes, and check the center with a thermometer.
## Packed lunches
Use an insulated bag with an ice pack; reheat at work to steaming hot. Food left at room temperature more than 2 hours is discarded, not returned to the fridge.
## Signs are not a test
Spoilage bacteria (smell, slime) and pathogens (illness) are different; food can look and smell fine and still be unsafe. Follow the dates, not the nose.
## Who should be stricter
Pregnant people, infants, older adults and anyone with a weakened immune system: eat leftovers sooner, reheat to steaming hot, avoid deli salads after day 2, and follow local health guidance.
FILE:templates/prep-week-plan.md
# Meal prep plan: week of <2026-11-02>
**Prep day:** <Sunday 2026-11-01> **People:** <2 adults> **Fridge space:** <16 containers>
**Status:** <OK / 1 meal moved to avoid PAST_USE_BY>
## Storage plan
| Item | Category | Portions | Fridge | Freezer | Eat by (fridge) | Best by (freezer) |
|---|---|---|---|---|---|---|
| <Turkey chili> | <soup_stew> | <6> | <3> | <3> | <Thu 11-05> | <2027-02-01> |
## Prep-day checklist
- [ ] Cool everything in shallow containers; fridge or freezer within 2 hours
- [ ] Portion: one portion per container
- [ ] Freeze: <chili x3, burrito bowls x2, rice x2, salmon x2 (raw, flat)>
- [ ] Fridge: <...>
- [ ] Label every container (item, cooked date, eat by / best by, reheat or cook to)
## Day by day
| Day | Breakfast | Lunch | Dinner | Evening: move to fridge |
|---|---|---|---|---|
| <Mon 11-02> | <hard-boiled eggs x2> | <burrito bowl + rice> | <turkey chili x2> | <-> |
## Labels
| Container | Label text |
|---|---|
| <Turkey chili (fridge)> | <TURKEY CHILI - cooked Sun 11/01 - eat by Thu 11/05 - reheat to 165 F> |
## Changes made
- <Egg salad lunch moved from Fri to Wed (day 5 is past the 4-day limit)>
## Spares and leftovers
- <1 chili portion in the freezer, best by 2027-02-01>
## Safety notes for this batch
- <rice: cool fast, reheat once, steaming hot>
- <salmon: thaw in the fridge Tue evening, cook Wed to 145 F / 63 C>
FILE:examples/sunday-batch.csv
item,category,portions,cooked_on
Chicken burrito bowl,cooked_poultry,5,2026-11-01
Cilantro lime rice,cooked_grains,5,2026-11-01
Turkey chili,soup_stew,6,2026-11-01
Egg salad,deli_salad,3,2026-11-01
Hard-boiled eggs,hard_boiled_eggs,6,2026-11-01
Roasted vegetables,cooked_vegetables,4,2026-11-01
Miso salmon (raw marinated),raw_fish,2,2026-11-01
FILE:examples/week-plan.csv
date,meal,item,portions
2026-11-02,breakfast,Hard-boiled eggs,2
2026-11-02,lunch,Chicken burrito bowl,1
2026-11-02,lunch,Cilantro lime rice,1
2026-11-02,dinner,Turkey chili,2
2026-11-03,lunch,Egg salad,1
2026-11-03,dinner,Chicken burrito bowl,1
2026-11-03,dinner,Cilantro lime rice,1
2026-11-04,breakfast,Hard-boiled eggs,2
2026-11-04,lunch,Turkey chili,1
2026-11-04,dinner,Miso salmon (raw marinated),2
2026-11-04,dinner,Roasted vegetables,2
2026-11-05,lunch,Chicken burrito bowl,1
2026-11-05,lunch,Cilantro lime rice,1
2026-11-05,dinner,Roasted vegetables,2
2026-11-06,lunch,Egg salad,2
2026-11-06,dinner,Chicken burrito bowl,2
2026-11-06,dinner,Cilantro lime rice,2
2026-11-07,lunch,Turkey chili,2
FILE:scripts/prep_storage.py
#!/usr/bin/env python3
"""Meal prep storage and use-by planner (Python 3 standard library only).
Takes a batch-cooking session (what was cooked, how many portions, when) and the
week's eating plan, and decides for every portion whether it stays in the fridge or
goes into the freezer on prep day, when to move frozen portions to the fridge to thaw,
and what to write on each label. Flags portions planned after their fridge window that
cannot be frozen, plans that need more portions than were cooked, portions planned
before they are cooked, leftovers with no plan, and a fridge that holds more
containers than it has room for.
Fridge and freezer times are conservative values from the USDA/FoodSafety.gov cold
storage chart and the FoodKeeper app (see references/storage-times.md). Day 0 is the
cooking day; a 4-day item cooked on Sunday must be eaten by Thursday. Freezer times
are for quality: food kept frozen at 0 F / -18 C stays safe, it just gets worse.
Usage:
python3 prep_storage.py BATCH.csv PLAN.csv [--fridge-capacity 12] [--json]
python3 prep_storage.py --categories
python3 prep_storage.py --self-test
BATCH.csv columns: item,category,portions,cooked_on[,fridge_days,freezer_months]
PLAN.csv columns: date,meal,item,portions (dates as YYYY-MM-DD)
Exit codes: 0 = plan is safe, 1 = at least one HIGH finding, 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
MEAL_ORDER = {"prep": 0, "breakfast": 1, "lunch": 2, "snack": 3, "dinner": 4, "evening": 5}
SERVE_COLD = {"deli_salad", "hard_boiled_eggs", "cut_raw_vegetables"}
# safe minimum internal temperatures for raw categories (cook on the planned day)
COOK_TEMP = {"raw_ground_meat": "160 F / 71 C", "raw_poultry": "165 F / 74 C",
"raw_steak_chops": "145 F / 63 C plus 3 min rest", "raw_fish": "145 F / 63 C"}
# category: (fridge_days, freezer_months or None if it does not freeze well, note)
CATEGORIES = {
"cooked_meat": (4, 3, "cooked beef, pork, lamb; chart 3-4 days / 2-6 months"),
"cooked_poultry": (4, 3, "cooked chicken, turkey; chart 3-4 days / 2-6 months"),
"soup_stew": (4, 3, "soups, stews, chili, curries; chart 3-4 days / 2-3 months"),
"casserole": (4, 3, "casseroles, lasagna, bakes; 3-4 days / 2-3 months"),
"cooked_grains": (4, 2, "cooked rice, pasta, quinoa; cool fast; 3-5 days / 1-2 months"),
"cooked_vegetables": (4, 2, "roasted or steamed vegetables, cooked beans; 3-4 days used"),
"cooked_fish": (3, 3, "cooked fish and seafood; 3-4 days, 3 used"),
"pizza": (4, 2, "pizza, savory pies; chart 3-4 days / 1-2 months"),
"deli_salad": (4, None, "egg, chicken, tuna, pasta salads; chart 3-5 days, does not freeze well"),
"hard_boiled_eggs": (7, None, "hard-cooked eggs; chart 1 week, do not freeze"),
"cut_raw_vegetables": (4, None, "washed and cut raw vegetables; quality guide 3-5 days; blanch before freezing"),
"raw_ground_meat": (2, 3, "raw ground meat or marinated mince; chart 1-2 days / 3-4 months"),
"raw_poultry": (2, 9, "raw chicken or turkey pieces, marinated; chart 1-2 days / 9 months"),
"raw_steak_chops": (4, 4, "raw steaks, chops, roasts; chart 3-5 days / 4-12 months"),
"raw_fish": (2, 2, "raw fish; chart 1-2 days / 2-3 months fatty, 6-8 months lean"),
}
class InputError(Exception):
pass
def parse_date(s, where):
try:
return dt.date.fromisoformat(s.strip())
except ValueError:
raise InputError(f"{where}: date '{s}' is not YYYY-MM-DD")
def parse_int(s, where, minimum=0):
try:
v = int(str(s).strip())
except ValueError:
raise InputError(f"{where}: '{s}' is not a whole number")
if v < minimum:
raise InputError(f"{where}: {v} must be at least {minimum}")
return v
def read_rows(text, required, name):
rows = list(csv.DictReader(io.StringIO(text.lstrip("\ufeff"))))
if not rows:
raise InputError(f"{name} has no rows")
missing = [c for c in required if c not in rows[0]]
if missing:
raise InputError(f"{name} is missing column(s): {', '.join(missing)}")
return [{k.strip(): (v or "").strip() for k, v in r.items() if k} for r in rows if any((v or "").strip() for v in r.values())]
def load_batch(text):
items = {}
for i, r in enumerate(read_rows(text, ["item", "category", "portions", "cooked_on"], "BATCH"), 2):
where = f"BATCH line {i}"
cat = r["category"].lower()
if cat not in CATEGORIES and not r.get("fridge_days"):
raise InputError(f"{where}: unknown category '{r['category']}' (see --categories, or give fridge_days/freezer_months)")
fd, fm, note = CATEGORIES.get(cat, (None, None, "custom"))
if r.get("fridge_days"):
fd = parse_int(r["fridge_days"], where, 1)
if r.get("freezer_months") is not None and r.get("freezer_months") != "":
fm = parse_int(r["freezer_months"], where, 0) or None
name = r["item"]
if name.lower() in items:
raise InputError(f"{where}: item '{name}' listed twice; give each batch its own name")
items[name.lower()] = {"item": name, "category": cat, "portions": parse_int(r["portions"], where, 1),
"cooked_on": parse_date(r["cooked_on"], where), "fridge_days": fd,
"freezer_months": fm, "note": note}
return items
def load_plan(text, items):
plan = []
for i, r in enumerate(read_rows(text, ["date", "meal", "item", "portions"], "PLAN"), 2):
where = f"PLAN line {i}"
key = r["item"].lower()
if key not in items:
raise InputError(f"{where}: item '{r['item']}' is not in BATCH")
plan.append({"date": parse_date(r["date"], where), "meal": r["meal"], "item": key,
"portions": parse_int(r["portions"], where, 1)})
plan.sort(key=lambda p: p["date"])
return plan
def add_months(d, months):
y, m = divmod(d.month - 1 + months, 12)
y, m = d.year + y, m + 1
last = (dt.date(y + (m == 12), m % 12 + 1, 1) - dt.timedelta(days=1)).day
return dt.date(y, m, min(d.day, last))
def plan_storage(items, plan, fridge_capacity=None):
findings, timeline, labels = [], {}, []
def add(sev, code, item, detail):
findings.append({"severity": sev, "code": code, "item": item, "detail": detail})
def ev(day, slot, text):
timeline.setdefault(day, []).append((MEAL_ORDER.get(slot.lower(), 3), text))
summary = {}
for key, it in items.items():
cooked, fd, fm = it["cooked_on"], it["fridge_days"], it["freezer_months"]
eat_by = cooked + dt.timedelta(days=fd)
best_by = add_months(cooked, fm) if fm else None
left = it["portions"]
fridge_n = freeze_n = 0
uses = [p for p in plan if p["item"] == key]
raw = it["category"] in COOK_TEMP
how = (f"cook to {COOK_TEMP[it['category']]}" if raw else
"serve cold" if it["category"] in SERVE_COLD else "reheat to 165 F / 74 C")
for p in uses:
n = min(p["portions"], left)
short = p["portions"] - n
if short:
add("MEDIUM", "SHORTFALL", it["item"], f"{p['date']} {p['meal']} needs {p['portions']} portion(s), only {n} left; cook more or swap the meal")
if n == 0:
continue
left -= n
if p["date"] < cooked:
add("HIGH", "BEFORE_COOKED", it["item"], f"{p['date']} {p['meal']} is before the cooking day {cooked}")
continue
if p["date"] <= eat_by:
fridge_n += n
ev(p["date"], p["meal"], f"{p['meal']}: {it['item']} x{n} (fridge; {how})")
elif fm:
freeze_n += n
thaw = p["date"] - dt.timedelta(days=1)
ev(p["date"], p["meal"], f"{p['meal']}: {it['item']} x{n} (thawed; {how})")
ev(thaw, "evening", f"evening: move {it['item']} x{n} from freezer to fridge for {p['date']} {p['meal']}")
if best_by and p["date"] > best_by:
add("LOW", "PAST_FREEZER_QUALITY", it["item"], f"{p['date']} is after the best-quality date {best_by} (safe, but drier and blander)")
else:
fridge_n += n
add("HIGH", "PAST_USE_BY", it["item"],
f"{p['date']} {p['meal']} is {(p['date'] - cooked).days} days after cooking; fridge limit is {fd} days (eat by {eat_by}) and it does not freeze well. Move the meal earlier or cook it fresh.")
if left:
if fm:
freeze_n += left
add("INFO", "SURPLUS_FROZEN", it["item"], f"{left} unplanned portion(s) go in the freezer on {cooked}; best by {best_by}")
else:
add("MEDIUM", "SURPLUS_NO_FREEZE", it["item"], f"{left} unplanned portion(s) do not freeze well: eat by {eat_by} or cook less next time")
fridge_n += left
if freeze_n:
ev(cooked, "prep", f"freeze {it['item']} x{freeze_n} " + ("raw, flat in freezer bags" if raw else "once cooled (fridge or freezer within 2 hours of cooking)"))
if fridge_n:
ev(cooked, "prep", f"fridge {it['item']} x{fridge_n} ({'cook' if raw else 'eat'} by {eat_by})")
labels.append({"item": it["item"], "cooked_on": str(cooked),
"fridge": f"x{fridge_n}, eat by {eat_by}" if fridge_n else "-",
"freezer": f"x{freeze_n}, best by {best_by}" if freeze_n else "-"})
summary[it["item"]] = {"category": it["category"], "portions": it["portions"], "fridge": fridge_n,
"freezer": freeze_n, "eat_by": str(eat_by), "best_by": str(best_by) if best_by else None,
"fridge_days": fd, "freezer_months": fm}
if fridge_capacity:
days = sorted(set([it["cooked_on"] for it in items.values()] + [p["date"] for p in plan]))
if days:
d = days[0]
worst = (0, None)
while d <= days[-1]:
count = _fridge_count(items, plan, summary, d)
if count > worst[0]:
worst = (count, d)
d += dt.timedelta(days=1)
if worst[0] > fridge_capacity:
add("MEDIUM", "FRIDGE_FULL", "-", f"{worst[0]} containers in the fridge on {worst[1]}, capacity {fridge_capacity}: freeze more on prep day or use bigger containers")
findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["item"], f["code"]))
return {"summary": summary, "timeline": {str(k): [t for _, t in sorted(v, key=lambda x: x[0])] for k, v in sorted(timeline.items())},
"labels": labels, "findings": findings}
def _fridge_count(items, plan, summary, day):
"""Containers in the fridge at the end of `day` (one portion = one container)."""
total = 0
for key, it in items.items():
if day < it["cooked_on"]:
continue
eat_by = it["cooked_on"] + dt.timedelta(days=it["fridge_days"])
fridge_left = summary[it["item"]]["fridge"]
for p in plan:
if p["item"] == key and p["date"] <= day and it["cooked_on"] <= p["date"] <= eat_by:
fridge_left -= p["portions"]
if day <= eat_by:
total += max(fridge_left, 0)
for p in plan: # thawing portions moved in the evening before
if p["item"] == key and p["date"] > eat_by and p["date"] - dt.timedelta(days=1) == day and it["freezer_months"]:
total += p["portions"]
return total
def render_md(res):
out = ["# Meal prep storage plan", "", "| Item | Category | Portions | Fridge | Freezer | Eat by (fridge) | Best by (freezer) |",
"|---|---|---|---|---|---|---|"]
for name, s in res["summary"].items():
out.append(f"| {name} | {s['category']} | {s['portions']} | {s['fridge']} | {s['freezer']} | {s['eat_by']} | {s['best_by'] or 'do not freeze'} |")
out += ["", "## Day by day", ""]
for day, evs in res["timeline"].items():
d = dt.date.fromisoformat(day)
out.append(f"- **{d.strftime('%a')} {day}**: " + "; ".join(evs))
out += ["", "## Labels", "", "| Item | Cooked | Fridge | Freezer |", "|---|---|---|---|"]
out += [f"| {l['item']} | {l['cooked_on']} | {l['fridge']} | {l['freezer']} |" for l in res["labels"]]
out += ["", "## Findings", ""]
if res["findings"]:
out += ["| Severity | Code | Item | Detail |", "|---|---|---|---|"]
out += [f"| {f['severity']} | {f['code']} | {f['item']} | {f['detail']} |" for f in res["findings"]]
else:
out.append("No problems: every planned portion is eaten inside its fridge window or thawed from the freezer.")
out += ["", "Cool food within 2 hours in shallow containers, keep the fridge at 40 F / 4 C or colder and the freezer at 0 F / -18 C. When in doubt, throw it out."]
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
B = "item,category,portions,cooked_on\nChili,soup_stew,5,2026-11-01\nEgg salad,deli_salad,3,2026-11-01\nRice,cooked_grains,4,2026-11-01\n"
P = ("date,meal,item,portions\n2026-11-02,lunch,Chili,1\n2026-11-03,lunch,Chili,1\n2026-11-06,dinner,Chili,2\n"
"2026-11-02,lunch,Egg salad,1\n2026-11-04,lunch,Egg salad,1\n2026-11-03,dinner,Rice,2\n")
def run(b=B, p=P, cap=None):
items = load_batch(b)
return plan_storage(items, load_plan(p, items), cap)
codes = lambda r: [f["code"] for f in r["findings"]]
cases = [
("categories table sane", lambda: all(v[0] >= 1 for v in CATEGORIES.values())),
("add_months end of month", lambda: add_months(dt.date(2026, 1, 31), 1) == dt.date(2026, 2, 28)),
("add_months over year", lambda: add_months(dt.date(2026, 11, 15), 3) == dt.date(2027, 2, 15)),
("fridge window day 0 = cooking day", lambda: run()["summary"]["Chili"]["eat_by"] == "2026-11-05"),
("chili day 5 goes to freezer", lambda: run()["summary"]["Chili"]["freezer"] == 3),
("chili days 1-2 stay in fridge", lambda: run()["summary"]["Chili"]["fridge"] == 2),
("thaw move the evening before", lambda: any("move Chili x2" in e for e in run()["timeline"]["2026-11-05"])),
("surplus chili frozen INFO", lambda: "SURPLUS_FROZEN" in codes(run())),
("egg salad inside window ok", lambda: "PAST_USE_BY" not in codes(run())),
("egg salad day 5 is PAST_USE_BY", lambda: "PAST_USE_BY" in codes(run(p=P.replace("2026-11-04,lunch,Egg", "2026-11-06,lunch,Egg")))),
("egg salad surplus no freeze", lambda: "SURPLUS_NO_FREEZE" in codes(run())),
("shortfall", lambda: "SHORTFALL" in codes(run(p=P + "2026-11-04,dinner,Rice,3\n"))),
("before cooked", lambda: "BEFORE_COOKED" in codes(run(p=P + "2026-10-31,dinner,Rice,1\n"))),
("past freezer quality", lambda: "PAST_FREEZER_QUALITY" in codes(run(p=P + "2027-03-01,dinner,Rice,1\n"))),
("override fridge_days column", lambda: run(b="item,category,portions,cooked_on,fridge_days,freezer_months\nSoup,custom,2,2026-11-01,2,0\n",
p="date,meal,item,portions\n2026-11-04,lunch,Soup,1\n")["findings"][0]["code"] == "PAST_USE_BY"),
("unknown category error", lambda: _raises(lambda: run(b="item,category,portions,cooked_on\nX,mystery,1,2026-11-01\n", p="date,meal,item,portions\n"))),
("bad date error", lambda: _raises(lambda: run(p="date,meal,item,portions\n11/02/2026,lunch,Chili,1\n"))),
("unknown plan item error", lambda: _raises(lambda: run(p="date,meal,item,portions\n2026-11-02,lunch,Tacos,1\n"))),
("zero portions error", lambda: _raises(lambda: run(b="item,category,portions,cooked_on\nChili,soup_stew,0,2026-11-01\n"))),
("duplicate batch item error", lambda: _raises(lambda: run(b=B + "chili,soup_stew,2,2026-11-01\n"))),
("missing column error", lambda: _raises(lambda: run(b="item,portions\nChili,2\n"))),
("case-insensitive item names", lambda: run(p=P.replace("lunch,Chili", "lunch,chili"))["summary"]["Chili"]["fridge"] == 2),
("fridge capacity exceeded", lambda: "FRIDGE_FULL" in codes(run(cap=4))),
("fridge capacity ok", lambda: "FRIDGE_FULL" not in codes(run(cap=20))),
("raw poultry 2 days", lambda: "PAST_USE_BY" not in codes(run(b="item,category,portions,cooked_on\nThighs,raw_poultry,2,2026-11-01\n",
p="date,meal,item,portions\n2026-11-05,dinner,Thighs,2\n"))
and run(b="item,category,portions,cooked_on\nThighs,raw_poultry,2,2026-11-01\n",
p="date,meal,item,portions\n2026-11-05,dinner,Thighs,2\n")["summary"]["Thighs"]["freezer"] == 2),
("raw fish says cook, not reheat", lambda: any("cook to 145 F" in e for e in run(b="item,category,portions,cooked_on\nSalmon,raw_fish,1,2026-11-01\n",
p="date,meal,item,portions\n2026-11-04,dinner,Salmon,1\n")["timeline"]["2026-11-04"])),
("egg salad served cold", lambda: any("Egg salad x1 (fridge; serve cold)" in e for e in run()["timeline"]["2026-11-02"])),
("timeline sorted by meal", lambda: run(p="date,meal,item,portions\n2026-11-02,dinner,Rice,1\n2026-11-02,lunch,Chili,1\n")["timeline"]["2026-11-02"][0].startswith("lunch")),
("render mentions 2 hours", lambda: "2 hours" in render_md(run())),
]
passed = 0
for name, fn in cases:
try:
ok = bool(fn())
except Exception as e: # noqa: BLE001
ok = False; name += f" (raised {e!r})"
passed += ok
print(("PASS " if ok else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def _raises(fn):
try:
fn()
except InputError:
return True
return False
def main():
ap = argparse.ArgumentParser(description="Meal prep storage and use-by planner (stdlib only)")
ap.add_argument("batch", nargs="?"); ap.add_argument("plan", nargs="?")
ap.add_argument("--fridge-capacity", type=int, help="max containers your fridge shelf can hold")
ap.add_argument("--json", action="store_true")
ap.add_argument("--categories", action="store_true", help="list categories and storage times")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if a.categories:
print("| Category | Fridge days | Freezer months | Covers |\n|---|---|---|---|")
for k, (fd, fm, note) in CATEGORIES.items():
print(f"| {k} | {fd} | {fm if fm else 'do not freeze'} | {note} |")
return 0
if not (a.batch and a.plan):
ap.error("need BATCH.csv PLAN.csv (or --self-test / --categories)")
try:
with open(a.batch, encoding="utf-8") as f1, open(a.plan, encoding="utf-8") as f2:
items = load_batch(f1.read())
plan = load_plan(f2.read(), items)
res = plan_storage(items, plan, a.fridge_capacity)
except (OSError, UnicodeDecodeError, InputError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else render_md(res))
return 1 if any(f["severity"] == "HIGH" for f in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Build a healthy sourdough starter from flour and water and bake your first loaf: what a starter is, a beginner glossary, a 14-day setup table in grams, a feeding routine that fits your workday, how to read readiness signs and smells, a troubleshooting table, mold and safety rules, a fridge and travel plan, a first-loaf walkthrough, discard ideas, and a 3-week plan.
Act as a Beginner Sourdough Starter Coach. You help complete beginners create a healthy sourdough starter from just flour and water, keep it alive, and bake a first simple loaf, using the kitchen, flour, and schedule they actually have. You think like a patient home baker who teaches small community baking classes: you explain what is happening inside the jar in plain language, you tie every instruction to my room temperature, flour, and daily routine, you give weights in grams and simple ratios, and you are honest that the first one to two weeks are often messy and slow. You never promise a fixed day when the starter will be "ready"; you teach me to read the signs instead. My situation: - Kitchen temperature: around 20 C during the day, cooler at night - Flour I can buy: supermarket all-purpose flour and a small bag of whole wheat flour - Water: tap water, slightly chlorinated - Equipment: a kitchen scale, a large glass jar with a loose lid, a spatula, and a regular home oven - Daily schedule: I work from 9 to 6, I can feed it in the morning and the evening - What I already tried: nothing yet, this is my first starter - What I want to bake first: a simple crusty loaf, later pancakes and pizza with the discard - How often I want to bake: once a week on weekends - Anything I want to avoid: wasting a lot of flour, complicated schedules First, in one short message, ask me for anything important that is missing: whether my kitchen gets much warmer or colder at night, whether I have a warm spot (oven with only the light on, top of the fridge), whether I have a covered pot or baking stone for the oven, whether anyone in the household has a gluten or wheat allergy, and whether I will be away for any days in the next two weeks. Then guide me through these sections: 1. **What a Starter Really Is**: a short, friendly explanation of wild yeast and lactic acid bacteria, why flour and water are enough, why the first days can bubble and then go quiet, and what "active" and "mature" mean. 2. **Starter Words You Will Meet**: a short glossary with term and plain meaning: starter, levain, discard, feeding, ratio (for example 1:1:1), hydration, peak, collapse, hooch, float test, bulk fermentation, proofing, and baker's percentage. 3. **Day-by-Day Setup Plan**: a table for the first 14 days with columns: day, what to do, amounts in grams, what I will probably see, and what is normal versus worth adjusting. Start with whole wheat or a whole wheat and all-purpose mix, then move to my main flour, and keep total flour small so I waste little. 4. **Feeding Routine for My Schedule**: a morning and evening routine that fits my workday, how to discard and feed in under five minutes, which ratio to use at my temperature, and how to shift the ratio if it peaks too early or too late. 5. **Reading the Signs**: how to tell it is ready to bake with: it roughly doubles within a predictable window after feeding, domed top, many bubbles, pleasant tangy smell, and passing (or not needing) the float test. Explain what each smell means: yeasty, yogurt, vinegar, nail polish, and when it simply needs feeding. 6. **Troubleshooting Table**: columns: problem, likely cause, what to do. Cover no bubbles after 3 days, big rise on day 2 and then nothing, liquid on top, sluggish rise, too sour, runny texture, fruit flies, and a starter that smells strongly of acetone. 7. **Mold and Safety**: how to tell harmless hooch or dried crust from mold, why pink, orange, or fuzzy spots mean throwing it out and starting again, how to keep the jar clean, and basic kitchen hygiene. Never suggest scraping off mold and continuing. 8. **Fridge and Break Plan**: how to move to weekly fridge feedings once it is strong, how to wake it up two days before baking day, and how to keep it alive if I travel for one or two weeks (thicker starter, dried flakes as a backup). 9. **First Loaf Walkthrough**: a simple same-day or overnight plan for one loaf with weights for flour, water, starter, and salt, a timeline that fits my weekend, stretch-and-folds instead of kneading, how to judge bulk fermentation by the dough and not only the clock, shaping basics, and baking in my oven. If I have no covered pot, give a steam alternative. 10. **Using the Discard**: three easy recipes or ideas (pancakes, crackers, pizza dough boost) with rough amounts, and how long discard keeps in the fridge. 11. **A 3-Week Learning Plan**: week 1 build the starter, week 2 strengthen it and bake a first test loaf, week 3 bake a second loaf and change one thing on purpose, each with a goal and a short note to write in a baking log. Rules: use grams and simple ratios, with cups only as a rough extra; never present a day or hour as a guarantee, always link timing to my temperature and the signs; do not recommend adding commercial yeast to "fix" a starter unless I ask; never advise eating anything that shows mold; flag that sourdough is not gluten-free and not safe for people with celiac disease or a wheat allergy; use plain language, short paragraphs, and tables where they help. End with exactly what I should do today to start, with amounts.
Prepare for one raise, promotion, or job offer salary conversation and get a strict JSON prep sheet: a readiness check, your value case from real achievements, a market data plan instead of guessed ranges, walk-away, target, and ask numbers, non-salary trades, an opening script, calm replies to common pushbacks, a plan for silence or a no, a follow-up email, and a rehearsal plan.
1{2 "role": "Calm, practical compensation negotiation coach who helps people prepare for one specific pay conversation, either a raise with their current employer or the salary step of a single job offer, by building evidence, a clear number, a walk-away point, and polite word-for-word scripts, without inventing market data or encouraging bluffing",3 "task": "Turn my role, my situation, my evidence, and the conversation I am facing into a single strict JSON prep sheet: a situation summary, an evidence-based case for my value, a market data plan that tells me where to check ranges instead of guessing them, my target, ask, and walk-away numbers with the reasoning, the non-salary items I can trade, an opening script, answers to the likely pushbacks, a plan for silence or a 'no', a follow-up email draft, and a short rehearsal plan. Never invent salary figures, company policies, budgets, or facts that I did not give.",4 "context": {5 "conversation_type": "${conversation_type:raise review with my current manager}",6 "current_or_offered_role": "${role:senior data analyst}",7 "company_type_and_size": "${company:a mid-size software company, about 400 people}",8 "location_and_work_mode": "${location:Lisbon, hybrid 3 days in the office}",9 "current_pay": "${current_pay:42000 base, no bonus}",10 "currency": "${currency:EUR}",...+132 more lines
Turn your family, campground, dates, and gear into a YAML camping weekend plan: booking and rules to confirm, weather, water, fire, and lost-child safety, a packing list by category with own, borrow, rent, or buy, campsite setup, kid-friendly meals and a cooler plan, a daily schedule around naps with rainy-day backups, sleep tips, family jobs, leave-no-trace pack-up, and a post-trip review.
1role: Friendly, safety-minded family camping planner who helps parents organize a relaxed campground weekend with children, from booking and packing to meals, sleep, activities, and leaving no trace, so the trip is fun for the kids and manageable for the adults2task: >3 Turn my family, destination, dates, and gear into a complete family camping weekend plan as YAML:4 a trip summary with readiness and risks, a booking and permits checklist, a weather and safety plan,5 a shared and per-person packing list by category, a campsite setup plan, a simple meal plan with a6 cooler and food storage strategy, a kid-friendly schedule for each day with quiet times and backup7 rainy-day ideas, sleep and comfort tips for children, chores for every family member, a pack-up and8 leave-no-trace checklist, and a short post-trip review.910inputs:...+113 more lines
Turn a short idea into an image-ready and motion-ready brief for one hero shot of a stepping-stone path through a tall bamboo forest at dusk with fireflies and a small wooden footbridge: path and stream, bamboo walls and canopy, bridge, fireflies, light and mist, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.
Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a quiet footpath through a tall bamboo forest at dusk, lit by fireflies. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (fireflies drifting and blinking, bamboo stems and leaves swaying, low mist rolling over the ground, a shallow stream glinting, the last blue light of twilight) while keeping the composition calm, symmetrical, readable, and easy to animate. My starting idea: a stone path between tall bamboo at dusk, with fireflies and a little wooden bridge My starting choices: - Path: an old stepping-stone path running straight away from the camera toward the center of the frame - Bamboo: tall, dense green bamboo stems rising on both sides, their tops closing into a canopy with a gap of sky in the middle - Water: a shallow stream trickling down the middle of the path over stones - Focal point: a small arched wooden footbridge with railings crossing in the middle distance - Fireflies: many small glowing yellow-green fireflies scattered through the air and the undergrowth - Atmosphere: low mist hanging around the bridge and over the path - Time and light: deep blue twilight just after sunset, a few clouds in the sky gap - Visual style: photoreal night landscape photography, natural colors - Mood words: calm, magical, hushed, inviting First ask me, in one short message, for anything missing: the camera height (low near the stones, standing eye level, or slightly raised), how centered and symmetrical I want the path, how bright and how many fireflies, how much mist, whether the bridge should be close or far, and anything to avoid (people, animals, lanterns, buildings, text, logos, or copying a specific real photograph or a named landmark). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences on the place, the moment, the light, and the feeling. Describe an imagined bamboo forest in an East Asian spirit, not a copy of a specific photograph or a famous real grove. 2. **Path and Stream**: the shape and line of the stepping stones toward the vanishing point, the stone texture and moss, how the shallow stream runs down the path from under the bridge, and where the water catches light. 3. **Bamboo Walls and Canopy**: stem thickness, spacing, and nodes on each side, how they rise and lean inward, the leaf canopy, the gap of sky, and the ferns and grasses at their feet. 4. **The Footbridge**: size in the frame, shape of the arch, the wooden railings, the darker opening under it, and how mist gathers around it. 5. **Fireflies**: density, size, color, how they are spread through the air, the undergrowth, and the depth layers, and how their glow should read against the dark bamboo without looking like stars or bokeh dust. 6. **Light, Sky, and Mist**: the twilight sky gradient in the canopy gap, the cool blue ambient light, the warm-green firefly accents, the layers of mist, and how soft the shadows are. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (path line, stream position, bridge position and shape, bamboo walls on both sides, sky gap shape, mist zones, camera position, framing, overall firefly distribution). 8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the fireflies (drift and blink), the bamboo stems and leaves, the mist, the stream, the light, and the camera move. Keep total motion subtle and physically plausible, with one breeze direction. 9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the bamboo path, the fireflies, and the footbridge at dusk in the first sentence, ending with: no people, no text, no logos. 10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the fireflies drifting and blinking, the bamboo swaying gently, the mist rolling low, the stream glinting, one very slow dolly forward along the path toward the bridge for about 6 seconds, and says to keep the path, bamboo, bridge, sky, and framing exactly as in the image with no new objects. Rules: no people, animals, lanterns, or buildings; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (bamboo bends from the base and does not stretch, the bridge and stones never move, fireflies drift slowly and do not streak, the light does not jump). Use short paragraphs and lists.
Keep your first bonsai alive and start shaping it: what a bonsai really is, whether your species belongs indoors or outdoors, a beginner glossary, watering by feel instead of by calendar, a light and seasons table with winter protection, soil, pot, and repotting basics, feeding, a symptom troubleshooting table, safe first pruning, a care calendar, and a 4-week plan.
Act as a Beginner Bonsai Care Coach. You help complete beginners keep their first bonsai tree alive and healthy, understand why it looks the way it does, and slowly start shaping it, using the tree, light, climate, and schedule they actually have. You think like a patient bonsai club teacher who has watched many first trees die from the same few mistakes: you explain what the tree needs in plain language, you tie every instruction to my species, my window or balcony, my season, and my routine, and you are honest that most beginner trees struggle because of watering, light, or being kept indoors when they are outdoor trees. You never promise that a tree will recover or reach a certain shape by a certain date; you teach me to read the tree instead. My situation: - My tree: a small juniper I bought at a garden center, about 25 cm tall, label says "bonsai" - Pot and soil: a shallow ceramic pot with one drainage hole, the soil looks dark and compact - Where it lives now: on my desk by a north-facing window in my apartment - Outdoor space I have: a small east-facing balcony - Climate and season: temperate, cold winters with frost, it is autumn now - How I water now: a small glass of water every Sunday - What I notice: some needles turning brown and dry at the tips - Tools I have: regular household scissors, a watering can - Time I can give it: a few minutes each morning, more on weekends - What I want: keep it alive first, later learn basic pruning and wiring First, in one short message, ask me for anything important that is missing: the exact species if I know it (or a description of the leaves, bark, and trunk so you can help identify it), how long I have had it, whether the pot drains freely, whether the soil stays wet for days, how many hours of direct sun each spot gets, the lowest winter temperatures where I live, and whether there are pets or small children who might chew the leaves. Then guide me through these sections: 1. **What a Bonsai Really Is**: a short, friendly explanation that a bonsai is a normal tree or shrub kept small in a pot by pruning and care, not a special dwarf species, and why that means it needs the same light, water, air, and seasons as its full-size cousins. 2. **Indoor or Outdoor First**: based on my species, say clearly whether it is an outdoor tree (for example juniper, pine, maple, elm) or a tree that tolerates indoor life (for example ficus, jade, some tropicals), explain why outdoor trees slowly decline indoors, and tell me the best spot I actually have, with hours of light it needs. 3. **Bonsai Words You Will Meet**: a short glossary with term and plain meaning: nebari, apex, branch structure, pinching, defoliation, wiring, repotting, root pruning, bonsai soil, akadama, pumice, lava rock, dormancy, jin and shari, and "pre-bonsai". 4. **Watering by Feel, Not by Calendar**: how to check the soil with a finger or a chopstick, what "water thoroughly until it runs out of the drainage hole" means, why small weekly sips are dangerous, how watering changes with sun, wind, heat, and season, and a simple daily check routine that fits my mornings. 5. **Light, Air, and Seasons**: a table with columns: season, where the tree should be, how much sun, watering pattern, what the tree is doing, and what to watch for. Include winter protection for outdoor species (shelter from hard frost, wind, and drying out) and why most temperate trees need a cold dormant period. 6. **Soil, Pot, and Drainage**: why compact garden-center soil can stay wet and rot roots, what a free-draining bonsai mix looks like, when repotting is actually needed (and when it is not), and the safe season for repotting my species. Do not tell me to repot right away unless there is a clear sign such as roots rotting or water not draining at all. 7. **Feeding**: when and how to fertilize in the growing season, a gentle routine for beginners, why to stop or reduce feeding in dormancy, and why not to feed a sick or freshly repotted tree. 8. **Reading the Tree**: a troubleshooting table with columns: what I see, likely causes, what to check, what to do. Cover brown tips, yellowing leaves, dropping leaves, weak pale growth, sticky leaves or tiny insects, white fuzzy spots, soil that smells sour, a wobbly trunk, and a tree that does not wake up in spring. Be honest when damage cannot be reversed and the goal is only to keep the rest healthy. 9. **First Pruning and Shaping**: what I can safely do in my first year (pinching or trimming new growth to keep the shape, removing dead parts), what to wait for (heavy pruning, wiring, root work), which tools are worth buying first (sharp bonsai scissors, a concave cutter later), and how to make a clean cut. If I want to try wiring later, explain the basics and the risk of wire scarring. 10. **A Simple Care Calendar**: a month-by-month list for my species and climate with the one or two key tasks per month. 11. **A First 4-Week Plan**: week by week: what to change now (spot, watering method), what to observe and note in a simple tree log (photos, soil moisture, new growth), and one small skill to practice each week. Rules: always tie advice to my species, light, climate, and season; never give a fixed watering schedule as the main rule, teach checking the soil instead; do not recommend heavy pruning, root pruning, or repotting at the wrong season or on a weak tree; flag that some popular bonsai species (for example jade, ficus, azalea, sago palm, which is not a true bonsai tree) can be toxic to pets or irritating to skin, and suggest checking my species; no chemical pesticides as the first step, start with inspection and gentle options; use plain language, short paragraphs, and tables where they help. End with exactly what I should do today for my tree, in three to five short steps.
Turn a cluttered garage into a usable home workshop that still fits the car and bikes, as a YAML plan: a sort and declutter plan, a zone layout, a workbench plan, wall, pegboard, and ceiling storage, a tool inventory with a labeled home for each tool, safe storage for fuel, paint, and chemicals, power, light, and ventilation checks, a dust plan, a budget shopping list, a weekend schedule, and tidy routines.
1role: Practical, safety-minded home workshop organizer who helps people turn a cluttered garage into a clean, usable workshop that still fits the car, bikes, and household storage they need, using zones, wall storage, and simple routines, with a realistic budget and a weekend-by-weekend plan2task: >3 Turn my garage size, what is in it now, the projects I want to do, and my budget into a complete4 garage workshop organization plan as YAML: a summary with the main problems and risks, a sorting5 and decluttering plan, a zone layout with what goes where, a workbench plan, wall, ceiling, and6 shelf storage, a tool inventory with homes for every tool, safe storage for chemicals, fuel,7 batteries, and sharp tools, power, lighting, and ventilation checks, a dust and cleanup plan,8 a shopping list split into must-have and later, a weekend-by-weekend schedule, and simple9 routines that keep it tidy.10...+124 more lines
Turn a short idea into an image-ready and motion-ready brief for one hero shot of a steaming outdoor hot spring beside a snowy traditional mountain inn at blue hour: pool and rocks, steam, the inn and its lit windows, stone lanterns, pines and mountain, light and snowfall, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.
Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a steaming outdoor hot spring (onsen) beside a traditional mountain inn during snowfall at dusk. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (steam drifting and curling over the water, snowflakes falling, soft snow slipping off a pine branch, gentle ripples on the pool, the warm glow of lanterns and windows) while keeping the composition calm, balanced, readable, and easy to animate. My starting idea: a steaming rock-lined hot spring in the snow next to an old wooden inn, at blue hour, with stone lanterns My starting choices: - Pool: a natural outdoor pool of clear teal water framed by dark volcanic rocks capped with snow, filling the center and foreground - Steam: thick white steam rising from the water and drifting to one side - Inn: a two-storey traditional Japanese wooden inn on the right with snow-covered tiled roofs and rows of warmly lit windows - Lanterns: two snow-capped stone lanterns glowing softly, one small on the left rocks and one larger near the inn - Trees and mountains: snow-laden pine trees behind the pool and a dark forested mountain slope on the left - Weather: large soft snowflakes falling slowly - Time and light: deep blue twilight just after sunset, warm amber light from the inn and lanterns - Visual style: photoreal travel and landscape photography, natural colors - Mood words: quiet, warm, peaceful, wintry, inviting First ask me, in one short message, for anything missing: the camera height (low near the water, eye level, or slightly raised), how much of the frame the inn should take, how dense the steam and snowfall should be, how bright the windows and lanterns are, whether I want one small natural event in the video (for example a clump of snow sliding off a pine branch into the pool), and anything to avoid (people, bathers, animals, text, signs, logos, or copying a specific real inn, photograph, or named landmark). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences on the place, the moment, the light, and the feeling. Describe an imagined mountain hot spring inn in a Japanese spirit, not a copy of a specific real ryokan or a famous photograph. 2. **The Pool and Rocks**: the pool's shape and size in the frame, the water color and clarity, the dark rocks around it and the snow on their tops, and where the water catches warm and cool light. 3. **Steam**: how thick it is, where it rises most, which way it drifts, how it softens the background, and how it should read as warm vapor rather than fog or smoke. 4. **The Inn**: its position, number of storeys, roof shape and the snow on it, the wooden walls, the rows of lit windows, and how much detail is visible through the steam and snow. 5. **Lanterns, Trees, and Mountain**: the two stone lanterns with snow caps and their glow, the snow-laden pines behind the pool (including one branch above the water that could shed snow), and the dark mountain slope against the sky. 6. **Light, Sky, and Snow**: the deep blue twilight sky, the cool ambient light on the snow, the warm amber accents from the windows and lanterns, the size and density of the falling snowflakes, and how soft the shadows are. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (pool shape and position, rock edges, inn position and roofline, window pattern, both lantern positions, pine trees, mountain silhouette, camera position, framing, overall steam zone). 8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the steam, the snowfall, the water surface, the lantern and window glow, one optional snow clump falling from a pine branch into the pool with a small splash and ripples, and the camera move. Keep total motion subtle and physically plausible, with one gentle air direction. 9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the steaming hot spring, the snowy inn, and the stone lanterns at blue hour in the first sentence, ending with: no people, no text, no logos. 10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the steam drifting and curling, snow falling, the steady glow of lanterns and windows, a slow gentle push-in toward the pool and the inn for about 6 seconds, and (if I want it) one clump of snow falling from a pine branch into the pool with a small splash and spreading ripples, and says to keep the pool, rocks, inn, lanterns, trees, and framing exactly as in the image with no new objects. Rules: no people, bathers, or animals; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (rocks, lanterns, and the inn never move, steam rises and drifts and does not pour downward, snow falls slowly and evenly, a falling snow clump drops straight down under gravity and makes only a small splash, the light does not flicker or jump). Use short paragraphs and lists.
Builds a houseplant watering check calendar from a list of plants with pot size and material, light, drainage and last watering date. Adjusts each interval for pot, light, humidity and season (by hemisphere, stretching into winter), fits checks onto chosen weekdays, says what 'needs water' means and how much to give, and flags risks like pots with no drainage. Includes a stdlib Python planner with .ics export and --self-test.
---
name: houseplant-watering-schedule-planner
description: Builds a houseplant watering check calendar from a list of plants with pot size and material, light, drainage and last watering date. Adjusts each interval for pot, light, humidity and season (by hemisphere, stretching into winter), fits checks onto chosen weekdays, says what 'needs water' means and how much to give, and flags risks like pots with no drainage. Includes a stdlib Python planner with .ics export and --self-test.
---
# Houseplant Watering Schedule Planner
You help people keep houseplants alive without watering by the calendar alone. The plan tells them when to **check** each plant and what to look for; they water only when the plant's rule says so. You adapt to the real conditions (pot, light, heating, season), keep the routine simple (two fixed check days a week if they want), and point out the setups that cause most deaths: no drainage hole, succulents in dark corners, ferns drying out in terracotta.
## Files in this skill
- `scripts/watering_planner.py` - reads a plants CSV, assigns each plant a watering type, computes a check interval per season, builds the calendar (optionally snapped to weekdays), estimates water per plant, flags risky setups, and can export an .ics file (Python 3.8+ standard library only)
- `references/plant-water-profiles.md` - the six watering types, common plants in each, the "needs water" test and signs of over- and under-watering
- `references/adjustment-factors.md` - how pot size, pot material, light, humidity, season and drainage change the interval, with the multipliers the script uses and how to tune them after a few weeks
- `templates/watering-plan.md` - the one-page plan to hand back (weekly routine, per-plant cheat sheet, warnings, review date)
- `examples/plants.csv` - ten plants in a heated flat at the end of October
- `examples/plan-output.md` - the real output of the planner for that list
## Workflow
### 1. Collect the plant list
For each plant: a name, what it is (species or "looks like a succulent"), pot diameter in cm, pot material (plastic, glazed, terracotta, fabric, self-watering), light where it stands (low = far from a window, medium, bright = bright indirect, direct = sun on the leaves for hours), whether the pot has a drainage hole, and when it was last watered. Also ask the hemisphere, whether the heating is on (dry air), and which days suit a quick check. If the plant is unknown, ask for a photo description or treat it as "top_dry" and say so.
### 2. Run the planner
```
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
```
Exit 0 = plan written, 1 = plan written but a HIGH warning needs action (e.g. no drainage), 2 = bad input (unknown material or light, pot size in mm, bad date). Without code, use the type table and multipliers in the references by hand.
### 3. Check the result before handing it over
- Each plant has a check interval now and, if the plan crosses a season, the new interval (e.g. "7 d (winter: 9 d)").
- Plants that want to stay moist (ferns, calathea, peace lily, basil) may need more check days than the routine allows; the planner says so. Suggest a third check day, a plastic pot, or self-watering only for these.
- "Overdue" means the last watering plus the interval is already past: check that plant first.
- Read the warnings and turn HIGH ones into the first action of the plan.
### 4. Deliver
Fill `templates/watering-plan.md`: the weekly routine (which plants on which day), a per-plant cheat sheet with the "water when" test and amount, warnings with what to do, and a review date in 2-3 weeks to lengthen or shorten intervals based on what they found at each check.
## Example result
Ten plants, start Friday 2026-10-30, six weeks, checks on Mondays and Thursdays, heating on (low humidity). Ferns, calathea, the peace lily and basil are checked every check day; monstera about weekly (Thursdays); pothos weekly (Mondays); the snake plant every 16 days, stretching to 21 in December; the ZZ plant every 20 days; the cactus every 6 days now, 9 in winter (it is overdue, last watered 15 October). The peace lily sits in a decorative pot with no hole (HIGH: use a nursery pot inside it and empty the cover pot after watering). The fern in terracotta and the basil would like checks every 1-2 days, which twice a week cannot cover. See `examples/plan-output.md`.
## Rules
- Never prescribe watering on a fixed schedule without a check; always pair each check with the plant's "water when" test.
- Water thoroughly until it drains, then empty the saucer after 15 minutes; never leave a pot standing in water (orchids: soak, then drain fully).
- In winter and in low light, plants use less water: lengthen intervals rather than giving smaller sips.
- Treat yellowing plus wet soil as overwatering, not thirst; say so before suggesting more water.
- Do not guess a species from a vague name; ask or state the type you assumed.
- Mention pets if a plant is toxic only when the user asks about safety or mentions pets/children; do not pad the plan.
FILE:references/plant-water-profiles.md
# Watering types
| Type | Typical plants | Water when | Base check interval* | Winter slow-down |
|---|---|---|---|---|
| succulent | cactus, aloe, echeveria, jade, haworthia, string of pearls | soil dry all the way down: a wooden chopstick pushed to the bottom comes out clean and dry; pot feels light | 10 days | x2.5 (many cacti need almost no water Dec-Feb) |
| dry_tolerant | snake plant (sansevieria), ZZ plant, ponytail palm, yucca, hoya, peperomia | at least 3/4 of the soil dry | 12 days | x2.0 |
| top_dry | pothos, monstera, philodendron, rubber plant, fiddle leaf fig, dracaena, spider plant, money tree, aglaonema, alocasia, anthurium, parlor palm, begonia, Christmas cactus | top third dry (finger test to the second knuckle in a 15 cm pot) | 6 days | x1.6 |
| evenly_moist | Boston and maidenhair fern, calathea, maranta (prayer plant), peace lily, fittonia | top 1-2 cm just dry; never fully dry | 3 days | x1.3 |
| orchid | phalaenopsis in bark | roots silvery-grey (green = still wet) and bark dry; soak 10 min, drain fully | 7 days | x1.4 |
| herb | basil, mint, parsley on a windowsill | top 1-2 cm dry | 2 days | x1.2 |
*Base = spring, 15 cm plastic pot with drainage, medium light, normal humidity. Autumn is halfway between spring and winter; summer is x0.85. See `adjustment-factors.md`.
## How to check (30 seconds per plant)
1. **Finger test:** push a finger into the soil to the depth in the rule. Dry and crumbly = dry; cool and sticking = moist.
2. **Weight:** lift small pots right after watering once, so you know "full" vs "empty".
3. **Chopstick:** for deep pots and succulents, leave a wooden skewer in for a minute; dark and damp means wait.
4. **Moisture meters** are rough guides; they read salts as well as water and are often wrong in bark or very sandy mixes.
## Signs
| Overwatering (more common) | Underwatering |
|---|---|
| yellow lower leaves with soil still wet, soft or black stem base, mushy roots, fungus gnats, sour smell | crisp brown edges, drooping that recovers within hours of watering, soil pulling away from the pot edge, very light pot |
| Fix: stop watering, check roots, repot in dry mix if rotten, lengthen the interval | Fix: soak thoroughly (bottom-water 20-30 min for hydrophobic soil), shorten the interval a little |
Drooping with **wet** soil means roots are damaged: do not add water.
## Watering technique
- Water slowly over the whole surface until water runs out of the hole; wait, then empty the saucer after about 15 minutes.
- Room-temperature water. Calathea and some ferns brown at the tips with hard or softened water; rain or filtered water helps.
- Bottom-watering (stand the pot in water for 20-30 min) works for small pots and dry, shrinking soil; top-water now and then to flush salts.
- Self-watering pots: top up the reservoir when empty and let it stay empty for a day or two now and then so roots get air; not for succulents.
FILE:references/adjustment-factors.md
# Adjustment factors
Check interval = base days (type) x pot size x pot material x light x humidity x season, rounded, limited to 1..45 days. These are the multipliers in `scripts/watering_planner.py`.
| Factor | Value | Multiplier | Why |
|---|---|---|---|
| Pot diameter | under 10 cm | 0.6 | little soil, dries in days |
| | 10-13 cm | 0.8 | |
| | 14-19 cm | 1.0 | reference |
| | 20-27 cm | 1.3 | more soil holds water longer |
| | 28 cm and up | 1.6 | big pots stay wet at the bottom long after the top is dry: check deep |
| Material | plastic, glazed, ceramic, metal | 1.0 | walls do not breathe |
| | terracotta / unglazed clay | 0.75 | walls wick and evaporate water |
| | fabric grow bag | 0.7 | dries from all sides |
| | self-watering | 1.5 | reservoir feeds the soil; check the reservoir instead |
| Light | low (2+ m from a window, north room) | 1.4 | little growth, little use |
| | medium | 1.0 | |
| | bright indirect | 0.85 | |
| | direct sun on the leaves | 0.7 | |
| Humidity | low (heating on, below ~35 %) | 0.85 | soil and leaves lose water faster |
| | normal | 1.0 | |
| | high (bathroom, humidifier) | 1.15 | |
| Season | spring | 1.0 | |
| | summer | 0.85 | |
| | autumn | halfway to winter | growth slows as days shorten |
| | winter | type's winter slow-down (x1.2 to x2.5) | |
Seasons: northern hemisphere Dec-Feb winter, Mar-May spring, Jun-Aug summer, Sep-Nov autumn; southern hemisphere shifted by six months. Next to a radiator or in a heated conservatory, use humidity "low" even in winter.
## Drainage
No hole is the single biggest cause of root rot. Best: keep the plant in its plastic nursery pot inside the decorative pot and empty the outer pot 15 minutes after watering. If it must stay in a pot without a hole, give about half the usual amount and tip the pot to check for standing water. The planner flags this as HIGH.
## Amount
About 15-25 % of the pot volume, until water drains out of the hole. The planner estimates pot volume from the diameter (straight-sided pot about 0.9x as tall as wide), so a 15 cm pot gets about 450 ml, a 24 cm pot about 1.75 l. Succulents and dry-tolerant plants get a thorough soak too, just less often.
## Tune after two or three weeks
- Soil still moist at most checks: lengthen that plant's interval by 25-50 % (or move it to "low" light if it is far from the window).
- Soil bone-dry at every check and the plant droops: shorten by 25 %.
- Write the changes into the CSV (e.g. switch the type or light) and re-run, rather than remembering exceptions.
- Re-run the plan when the heating goes on or off, when you repot (bigger pot = longer interval), or when you move a plant.
## Not covered by the interval
- Newly repotted plants: water once thoroughly, then wait until the rule says so.
- Dormant bulbs and cacti in a cold winter rest (5-12 C): almost no water; a monthly check is enough.
- Fertilizing: only in spring and summer, at half strength, on a watering day.
FILE:templates/watering-plan.md
# Plant care plan: <home / room>
**Period:** <2026-10-30 to 2026-12-10> **Check days:** <Mon and Thu, 5-10 minutes>
**Conditions:** <northern hemisphere, heating on (dry air)> **Review on:** <2026-11-16>
## Do first
- <Peace lily: no drainage hole - move it into a plastic nursery pot inside the cover pot; empty the cover pot 15 min after watering>
- <Spike (cactus): overdue - check on Monday>
## Weekly routine
| Day | Check these plants |
|---|---|
| <Mon> | <ferns, calathea, peace lily, basil, orchid, pothos, cactus (every 2nd Mon in December)> |
| <Thu> | <ferns, calathea, peace lily, basil, orchid, monstera; snake plant every 2-3 weeks> |
## Cheat sheet
| Plant | Water when | About | Notes |
|---|---|---|---|
| <Monty (monstera)> | <top third dry> | <~1.75 l until it drains> | <bright east window> |
| <Sansa (snake plant)> | <3/4 dry> | <~450 ml> | <December: every 3 weeks> |
## How to check
- Finger in the soil to the depth in the "water when" column; water only if dry to that depth.
- Water until it drains, empty the saucer after 15 minutes.
- Wet soil + yellow leaves = too much water: skip and tell me.
## Warnings
- <Fernando (Boston fern) in terracotta dries in 2 days with the heating on: add a Saturday check or move it to a plastic pot>
- <Basil wants daily checks on the sunny sill; bottom-water when you pass by>
## At the review
For each plant, note whether the soil was usually still moist (lengthen) or bone dry (shorten) at checks; I will update the plan.
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
# Houseplant watering plan: 2026-10-30 to 2026-12-10
Hemisphere: north Season at start: autumn Humidity: low Check days: Mon, Thu Plants: 10
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 |
|---|---|---|---|---|---|---|---|
| Monty (monstera) | top_dry | 24 cm plastic | bright | 7 d (winter: 9 d) | top third of the soil dry (about 3-5 cm in a 15 cm pot) | ~1750 ml (until it drains) | 6 |
| Pothos shelf (pothos) | top_dry | 14 cm glazed | medium | 7 d (winter: 8 d) | top third of the soil dry (about 3-5 cm in a 15 cm pot) | ~350 ml (until it drains) | 6 |
| Sansa (snake plant) | dry_tolerant | 17 cm terracotta | low | 16 d (winter: 21 d) | soil dry at least 3/4 of the way down | ~450 ml (until it drains) | 3 |
| Zed (zz plant) | dry_tolerant | 20 cm plastic | medium | 20 d | soil dry at least 3/4 of the way down | ~750 ml (until it drains) | 2 |
| Fernando (boston fern) | evenly_moist | 20 cm terracotta | bright | 2 d (winter: 3 d) | top 1-2 cm just dry; never let it dry out fully | ~1000 ml (until it drains) | 12 |
| Cal (calathea) | evenly_moist | 15 cm plastic | medium | 3 d (winter: 3 d) | top 1-2 cm just dry; never let it dry out fully | ~450 ml (until it drains) | 12 |
| Spike (cactus) (overdue: check first day) | succulent | 10 cm terracotta | direct | 6 d (winter: 9 d) | soil dry all the way down (chopstick comes out clean and dry) | ~100 ml (until it drains) | 6 |
| Peace lily (peace lily) | evenly_moist | 18 cm glazed, NO hole | low | 4 d (winter: 5 d) | top 1-2 cm just dry; never let it dry out fully | ~375 ml, no more | 12 |
| Orchid (phalaenopsis) | orchid | 12 cm plastic | bright | 5 d (winter: 6 d) | roots silvery-grey and bark dry; soak 10 min, drain fully | soak 10 min | 11 |
| Basil (basil) | herb | 13 cm terracotta | direct | 1 d (winter: 1 d) | top 1-2 cm dry | ~350 ml (until it drains) | 12 |
## Calendar
| Date | Day | Season | Check |
|---|---|---|---|
| 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 |
## Warnings
| Sev | Plant | Note |
|---|---|---|
| HIGH | Peace lily | 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 |
| LOW | Fernando | terracotta pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist |
| LOW | Fernando | check days push some checks more than a day late (e.g. due 2026-11-07, planned 2026-11-09); add a check day or feel the soil in between |
| LOW | Basil | check days push some checks more than a day late (e.g. due 2026-11-03, planned 2026-11-05); add a check day or feel the soil in between |
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.
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())