ziso-api / app /batchcal.py
rairo's picture
Deploy Ziso API
37851fa verified
Raw History Blame Contribute Delete
8.75 kB
"""The batch as a calendar, and any stretch of it in close-up."""
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from google.cloud import firestore
from . import config, flock, metrics, store, timeline
def _bounds(coop: dict) -> tuple[date, date]:
today = store.local(store.now()).date()
f = coop.get("flock")
if f:
placed = date.fromisoformat(f["placed"])
return placed, max(today, placed + timedelta(days=f.get("sell_day") or 35))
return today - timedelta(days=27), today
def _start(d: date) -> datetime:
return datetime.combine(d, time.min, tzinfo=config.TZ)
def _since(coop_id: str, name: str, start: datetime, end: datetime) -> list[dict]:
q = (store.coop_ref(coop_id).collection(name)
.where(filter=firestore.FieldFilter("ts", ">=", start))
.where(filter=firestore.FieldFilter("ts", "<", end)))
return [{"id": d.id, **d.to_dict()} for d in q.stream()]
def _tone(day: dict, alarm_n: int) -> str:
if not day.get("readings"):
return "none"
care = day.get("care") or 0
tone = "good" if care >= 85 else "watch" if care >= 70 else "act"
if day.get("dry_minutes", 0) >= 30:
tone = "act"
elif day.get("dry_minutes", 0) >= 10 and tone == "good":
tone = "watch"
if alarm_n and tone == "good":
tone = "watch"
return tone
def _plan(coop: dict) -> dict[str, list[str]]:
f = coop.get("flock")
if not f:
return {}
placed = date.fromisoformat(f["placed"])
marks: dict[str, list[str]] = defaultdict(list)
for day, text in flock.VACCINES:
marks[(placed + timedelta(days=day)).isoformat()].append("vaccine:" + text.split(" in the")[0])
for name, a, _ in flock.PHASES[1:]:
marks[(placed + timedelta(days=a)).isoformat()].append("feed:" + name)
for d in range(7, 50, 7):
marks[(placed + timedelta(days=d)).isoformat()].append("weigh")
marks[(placed + timedelta(days=f.get("sell_day") or 35)).isoformat()].append("sell")
return marks
def month(coop: dict) -> dict:
first, last = _bounds(coop)
today = store.local(store.now()).date()
start, end = _start(first), _start(last + timedelta(days=1))
hours = timeline.hours_between(coop["id"], start, min(end, store.now()))
by_day: dict[str, list[dict]] = defaultdict(list)
for h in hours:
by_day[h["hour"][:8]].append(h)
alarms_by, calls_by, ledger_by = defaultdict(int), defaultdict(int), defaultdict(list)
for a in _since(coop["id"], "alarms", start, end):
alarms_by[store.local(a["ts"]).date().isoformat()] += 1
for c in _since(coop["id"], "calls", start, end):
calls_by[store.local(c["ts"]).date().isoformat()] += 1
f = coop.get("flock")
if f:
for r in flock.ledger(coop["id"], f["batch"]):
ledger_by[store.local(r["ts"]).date().isoformat()].append(r["kind"])
plan = _plan(coop)
placed = date.fromisoformat(f["placed"]) if f else None
days = []
d = first
while d <= last:
key = d.isoformat()
summary = metrics._day(by_day.get(d.strftime("%Y%m%d"), []))
days.append({
"date": key, "age": (d - placed).days if placed else None,
"future": d > today, "today": d == today,
"tone": "future" if d > today else _tone(summary, alarms_by[key]),
"care": summary.get("care"), "alarms": alarms_by[key], "calls": calls_by[key],
"records": ledger_by[key], "plan": plan.get(key, []),
})
d += timedelta(days=1)
return {"first": first.isoformat(), "last": last.isoformat(), "today": today.isoformat(), "days": days}
def _frames(hours: list[dict]) -> list[dict]:
picks = []
for h in hours:
for n in h.get("notes", []):
if n.get("frame"):
picks.append({"ts": n["ts"], "frame": n["frame"], "text": n["text"]})
last_by_day: dict[str, dict] = {}
for h in hours:
if h.get("frame"):
last_by_day[h["hour"][:8]] = {"ts": h["last_ts"], "frame": h["frame"], "text": ""}
picks += list(last_by_day.values())
picks = sorted(picks, key=lambda p: p["ts"])[-4:]
return [{"time": store.local(p["ts"]).strftime("%a %H:%M"), "url": store.frame_url(p["frame"]),
"text": p["text"]} for p in picks]
def _story(summary: dict, per_day: list[dict], alarm_list: list[dict], n_days: int) -> str:
if not summary.get("readings"):
return "No camera readings in this stretch."
bits = []
calm = sum(1 for d in per_day if d.get("care") and d["care"] >= 85 and d.get("dry_minutes", 0) < 10)
bits.append(f"{calm} of {n_days} days calm and comfortable." if n_days > 1 else
("A calm, comfortable day." if calm else "A day that needed attention."))
if summary.get("dry_minutes", 0) >= 10:
bits.append(f"The drinker was dry for about {summary['dry_minutes']} minutes in all.")
if summary.get("cold", 0) >= 0.08:
bits.append(f"The flock huddled {round(summary['cold'] * 100)}% of the time.")
if summary.get("hot", 0) >= 0.05:
bits.append(f"They panted {round(summary['hot'] * 100)}% of the time.")
if alarm_list:
bits.append(f"{len(alarm_list)} alarm{'s' if len(alarm_list) > 1 else ''} raised.")
return " ".join(bits)
def span(coop: dict, first: date, last: date) -> dict:
if last < first:
first, last = last, first
last = min(last, first + timedelta(days=41))
start, end = _start(first), _start(last + timedelta(days=1))
hours = timeline.hours_between(coop["id"], start, min(end, store.now()))
summary = metrics._day(hours)
by_day: dict[str, list[dict]] = defaultdict(list)
for h in hours:
by_day[h["hour"][:8]].append(h)
n_days = (last - first).days + 1
keys = [(first + timedelta(days=i)).strftime("%Y%m%d") for i in range(n_days)]
per_day = [{"day": k, **metrics._day(by_day.get(k, []))} for k in keys]
grid = []
for k in keys[-14:]:
lookup = {h["hour"][8:10]: h for h in by_day.get(k, [])}
grid.append({"day": k, "hours": [metrics.cell(lookup.get(f"{i:02d}")) for i in range(24)]})
alarm_list = _since(coop["id"], "alarms", start, end)
calls = _since(coop["id"], "calls", start, end)
out = {
"first": first.isoformat(), "last": last.isoformat(), "days": n_days,
"summary": summary, "per_day": per_day, "grid": grid,
"story": _story(summary, per_day, alarm_list, n_days),
"alarms": [{"time": store.local(a["ts"]).strftime("%a %H:%M"), "message": a["message"],
"status": a["status"]} for a in sorted(alarm_list, key=lambda a: a["ts"])],
"calls": [{"time": store.local(c["ts"]).strftime("%a %H:%M"), "name": c.get("name") or "Caller",
"duration_s": c.get("duration_s"), "said": next((l["text"] for l in c.get("transcript", [])
if l.get("who") == "coop"), "")}
for c in sorted(calls, key=lambda c: c["ts"])],
"frames": _frames(hours),
"flock": None, "records": [],
}
f = coop.get("flock")
if f:
st = flock.state(coop)
placed = date.fromisoformat(f["placed"])
a0, a1 = (first - placed).days, (last - placed).days
factor = st["growth"]["factor"] if st else 0.75
alive = st["birds"]["alive"] if st else f["birds_placed"]
feed_kg = sum(flock.target(d)["feed_g"] or 0 for d in range(max(1, a0), max(1, a1) + 1)) * alive / 1000
out["flock"] = {
"age_from": a0, "age_to": a1,
"weight_from": round(flock.target(max(0, a0))["weight_g"] * factor / 1000, 2),
"weight_to": round(flock.target(max(0, a1))["weight_g"] * factor / 1000, 2),
"feed_kg": round(feed_kg), "feed_bags": round(feed_kg / flock.BAG_KG, 1),
"feed_usd": round(feed_kg * f["prices"]["feed_per_kg"]),
"plan": [{"date": k, "what": v} for k, v in sorted(_plan(coop).items()) if first.isoformat() <= k <= last.isoformat()],
}
out["records"] = [{"time": store.local(r["ts"]).strftime("%a %d %b"), "kind": r["kind"],
"quantity": r.get("quantity"), "unit": r.get("unit"), "amount_usd": r.get("amount_usd"),
"note": r.get("note")} for r in flock.ledger(coop["id"], f["batch"])
if first <= store.local(r["ts"]).date() <= last]
return out