# Project export: Acre

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Acre is an offline AI crop scouting system running on QNX that detects weeds, pests, and disease, points a laser at affected plants, and generates actionable farm health insights.
- Devpost: https://devpost.com/software/acre-z831bm
- GitHub: http://github.com/ArpitKhavate/Acre
- Team: 2 GitHub contributor(s) — Cursor (6 commits), ArpitKhavate (6 commits)

## Devpost submission (written by the team)

### Inspiration

Agriculture is one of the cornerstones of California's economy. As the largest agricultural producer in the United States, California generates over $61 billion in annual agricultural output and exports nearly $24 billion worth of agricultural products each year. Despite its scale, modern agriculture faces significant challenges that reduce crop yields and profitability. Plant diseases are estimated to cause 10–16% of global crop losses annually, while pests, invasive weeds, labor shortages, and insufficient field monitoring contribute to billions of dollars in additional agricultural losses. Farmers often need to manually inspect large areas of land, making it difficult to identify problems quickly and efficiently. We wanted to create a solution that could automate crop monitoring, provide actionable insights, and help farmers make better decisions while reducing manual workload. This challenge inspired us to build Acre, an intelligent crop scouting and farmland surveillance platform designed to modernize agricultural monitoring through edge computing and automation.

### What it does

Acre is a crop scouting and surveillance system that continuously monitors farmland to help farmers detect issues before they impact yields. The platform can: Detect and identify weed growth throughout fields Monitor crop health and field conditions Track pesticide and treatment applications Provide centralized monitoring of farmland operations Generate actionable insights to support farm management decisions Acre performs its analysis locally on QNX, allowing it to operate reliably even in remote agricultural environments with limited or unreliable internet connectivity. This makes it particularly well-suited for farms where cloud-based solutions may not always be practical.

### How we built it

We built Acre by combining embedded systems development, computer vision, AI-powered analytics, and real-time data processing into a unified platform. The system collects information from cameras and field sensors deployed throughout the farm. Computer vision models analyze captured images to identify weeds and monitor crop conditions, while additional monitoring systems track field treatments and pesticide usage. All processing is performed locally on hardware running QNX, enabling low-latency decision making and reducing dependence on cloud infrastructure. To present information in an accessible way, we developed a monitoring interface that aggregates field data and provides farmers with a clear view of farm conditions and potential issues requiring attention.

### Challenges we ran into

One of the biggest challenges was integrating multiple components, including sensors, cameras, AI models, and embedded software into a cohesive system also required significant testing and debugging to ensure reliable communication and data processing.

### Accomplishments we're proud of

We are proud of successfully creating a complete edge-based agricultural monitoring platform that can operate independently of cloud connectivity. Some of our key accomplishments include: Developing an automated crop scouting system capable of monitoring large areas of farmland Successfully deploying the platform on QNX for reliable edge operation Integrating computer vision to automate weed detection and crop monitoring Creating a centralized dashboard that simplifies farmland oversight Demonstrating how embedded AI can be applied to real-world agricultural challenges Most importantly, we built a solution that addresses a meaningful problem and has the potential to improve efficiency for farmers operating in remote environments.

### What we learned

Throughout the project, we gained valuable experience in embedded systems development, edge AI deployment, and real-time data processing. We learned how to optimize computer vision workloads for resource-constrained hardware, how to design software for unreliable network environments, and how to integrate multiple sensing technologies into a single platform. We also gained a deeper understanding of the challenges faced by modern agriculture and the importance of building technology that is both practical and reliable in real-world deployments. Working with QNX also gave us insight into developing applications for mission-critical systems where stability, fault tolerance, and deterministic performance are essential.

### What's next

Moving forward, we plan to expand Acre's capabilities by incorporating additional agricultural analytics and predictive intelligence. Future improvements include: Advanced crop disease detection using AI models Yield prediction and crop growth forecasting Automated irrigation recommendations based on environmental data Drone integration for large-scale field coverage Historical trend analysis and reporting tools Expanded support for additional sensor types and monitoring equipment Our long-term vision is to transform Acre into a comprehensive smart farming platform that empowers farmers with real-time insights, reduces manual labor, and helps maximize agricultural productivity through intelligent edge computing.

## README (from the GitHub repository)

# Acre — Handheld Plant Scanner

A field-deployable, offline-first AI plant scanner for the AI Hackathon @ Berkeley 2026.

You carry **Acre** and point it at a plant. On-device, it identifies the plant,
detects disease and pests, and computes a 0-100 health score — then lights an LED
on the Raspberry Pi: **green = healthy, red = needs treatment**. The red LED is the
spray-substitute ("treat this one"). Everything intelligent runs locally on the
Pi 5 / QNX board; the cloud is reporting-only (map, pesticide list, AI summary).

See the full spec in [docs/Acre_PRD.md](docs/Acre_PRD.md).

**QNX Pi + laptop demo (friend's setup):** [docs/DEMO_RUNBOOK.md](docs/DEMO_RUNBOOK.md)

## Architecture: local does the thinking, cloud does the reporting

```mermaid
flowchart LR
  subgraph edge [On device: Pi 5 + QNX, fully offline]
    cam[Pi Camera Module 3] --> det[YOLOv8n + disease/pest classifiers, ONNX]
    det --> score[Per-plant health score]
    score --> ledNode[Red/Green LED on Pi GPIO]
    score --> db[(Local SQLite, RTC-stamped)]
  end
  db -->|opportunistic sync| apiNode[Cloud API]
  subgraph cloud [Cloud: reporting only, no inference]
    apiNode --> pg[(Postgres / SQLite)]
    pg --> webNode[Web map + report]
    pg --> mon[Arize / Poke]
  end
```

## Repo layout

| Path | What it is |
|---|---|
| [docs/Acre_PRD.md](docs/Acre_PRD.md) | Product requirements document |
| `edge/` | On-device pipeline: capture, ONNX detection, classifiers, ArUco zones, health score, GPIO LED, SQLite, sync agent |
| `models/` | Off-device training + ONNX export for the 3 models |
| `cloud/` | FastAPI reporting backend (sync ingest, aggregation, Claude summary) |
| `web/` | Next.js dashboard: farm map, pesticide table, AI summary |
| `cloud/integrations/` | Optional Arize (monitoring) + Poke (conversational report) |

## Quickstart

### 1. Edge (runs on a laptop too, with stubs)

```bash
pip install -r requirements.txt
python -m edge.main --once          # single scan: prints finding + LED state
python -m edge.main                 # continuous handheld scan loop
python scripts/webcam_farm_demo.py    # webcam + press F → live health report in browser
```

On a dev laptop with no camera/models it uses synthetic frames, disables missing
model stages, and prints LED states. On the Pi it lights the real LED via GPIO.
Set `ACRE_SENSORS_ENABLED=1` to include the optional environmental sensors.

### 2. Cloud (reporting API)

```bash
pip install -r cloud/requirements.txt
python -m cloud.seed                                  # farm + zones + UC IPM + demo data
uvicorn cloud.app.main:app --reload --port 8000
```

Point the device at it: `ACRE_BACKEND_URL=http://<host>:8000/api/sync`.
Defaults to local SQLite; set `ACRE_DATABASE_URL` for Postgres/Supabase.

### 3. Web dashboard

```bash
cd web
npm install
cp .env.example .env.local
npm run dev                          # http://localhost:3000
```

### 4. Train the models (off-device)

```bash
cd models
pip install -r requirements-train.txt
python train_detector.py --data data/weed_crop/data.yaml --epochs 50
python train_disease_classifier.py --data data/plantvillage --epochs 15
python train_pest_classifier.py --data data/ip102_subset --epochs 15
# -> artifacts/*.onnx copied to the Pi; edge/detect.py loads them
```

## Key environment variables

| Var | Default | Used by |
|---|---|---|
| `ACRE_BACKEND_URL` | `http://localhost:8000/api/sync` | edge sync agent |
| `ACRE_HEALTH_THRESHOLD` | `70` | edge LED green/red cutoff |
| `ACRE_SENSORS_ENABLED` | `0` | edge optional sensors |
| `ACRE_DATABASE_URL` | `sqlite:///./acre_cloud.db` | cloud DB |
| `ANTHROPIC_API_KEY` | (unset → offline summary) | cloud AI summary |
| `NEXT_PUBLIC_ACRE_API` | `http://localhost:8000` | web app |

## Hardware (handheld build)

Pi 5 (QNX) + Pi Camera Module 3 (CSI) + RGB LED & buzzer & 1602 LCD on Pi GPIO +
DS1302 RTC. No servo, no laser, no Arduino on the critical path — the LED replaces
the laser/spray. Full inventory and wiring rationale in the PRD (sections 5-7).


## Detected evidence (automated analysis)

Indexed codebase: 69 recognized source files, 251 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- TypeScript (language) — detected in the code
- C (language) — claimed on Devpost, not found in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (88 of 88)

```
.gitignore
cloud/__init__.py
cloud/app/__init__.py
cloud/app/aggregate.py
cloud/app/ai_summary.py
cloud/app/db.py
cloud/app/main.py
cloud/app/models.py
cloud/app/schemas.py
cloud/integrations/__init__.py
cloud/integrations/arize_logger.py
cloud/integrations/poke_report.py
cloud/README.md
cloud/requirements.txt
cloud/schema.sql
cloud/seed.py
docs/Acre_PRD.md
docs/DEMO_RUNBOOK.md
docs/QNX_SETUP.md
edge/__init__.py
edge/aim.py
edge/aruco_zones.py
edge/capture.py
edge/config.py
edge/config/treatments.json
edge/config/zones.json
edge/detect.py
edge/drivers/__init__.py
edge/drivers/humiture.py
edge/health_score.py
edge/hwcheck.py
edge/lcd.py
edge/led.py
edge/local_db.py
edge/main.py
edge/report.py
edge/rtc.py
edge/seed_demo.py
edge/sensors.py
edge/session_report.py
edge/sync_agent.py
edge/treatments.py
models/.gitignore
models/artifacts/.gitkeep
models/bootstrap_all.py
models/build_closeup_yolo.py
models/common_classifier.py
models/data/README.md
models/labels/detector.json
models/labels/disease.json
models/labels/pest.json
models/notebooks/crop-vs-weed-using-yolov8.ipynb
models/notebooks/pest-classification.ipynb
models/notebooks/plant-disease-classification.ipynb
models/prepare_data.py
models/prepare_kaggle_data.py
models/README.md
models/requirements-train.txt
models/retrain_closeup.py
models/train_detector.py
models/train_disease_classifier.py
models/train_pest_classifier.py
README.md
requirements.txt
scripts/qnx_humiture_test.py
scripts/qnx_laser_test.sh
scripts/qnx_lcd_test.sh
scripts/qnx_led_test.sh
scripts/qnx_run_demo.sh
scripts/qnx_servo_test.py
scripts/start_laptop_demo.ps1
scripts/start_laptop_demo.sh
scripts/webcam_detect_test.py
scripts/webcam_farm_demo.py
web/.env.example
web/.gitignore
web/app/globals.css
web/app/layout.tsx
web/app/page.tsx
web/components/FarmMap.tsx
web/components/SummaryCard.tsx
web/components/TreatmentTable.tsx
web/lib/api.ts
web/live/index.html
web/next.config.js
web/package.json
web/README.md
web/tsconfig.json
```

### Dependencies

- cloud/requirements.txt: anthropic@==0.34.2, fastapi@==0.115.0, psycopg[binary]@==3.2.3, pydantic@==2.9.2, requests@==2.32.3, sqlalchemy@==2.0.35, uvicorn[standard]@==0.30.6
- requirements.txt: numpy@>=1.26,<3, onnxruntime@==1.19.2, opencv-contrib-python@==4.10.0.84, requests@==2.32.3
- web/package.json: @types/node@20.14.10, @types/react@18.3.3, @types/react-dom@18.3.0, next@14.2.5, react@18.3.1, react-dom@18.3.1, typescript@5.5.3

### Recent commits (newest first)

- Improve detection accuracy, add webcam farm demo, and fix QNX setup docs.
- Add QNX + laptop demo runbook and startup scripts
- Improve crop/weed detection and add offline ML pipeline
- Add LCD, servo+laser aiming, DHT11, and on-device offline report
- Make edge QNX-ready; drop LCD/RTC from build
- Initial commit: Acre handheld plant scanner

## Key source files (fetched from GitHub, selected and truncated for size)

### docs/DEMO_RUNBOOK.md

```markdown
# Acre demo runbook — QNX Pi + laptop report

Use this when one person has the **QNX Raspberry Pi** (camera + LED + LCD) and another
laptop on the **same Wi‑Fi** runs the **cloud API + web dashboard** (health scores,
pesticide table, AI summary).

```
  [QNX Pi]  scan + score + LED          [Laptop on Wi‑Fi]
       |    POST /api/sync  ----------------->  FastAPI (port 8000)
       |                                      Next.js (port 3000)
       +--- reads still JPEGs from            "Regenerate report" -> map + pesticides
            ACRE_FRAME_DIR (/tmp/frames)
```

Webcam testing on a dev laptop is separate: `python scripts/webcam_detect_test.py`.
On QNX you use **still images** from the camera folder bridge (see below).

---

## Roles

| Machine | Who | What runs |
|---------|-----|-----------|
| **Laptop** | Friend (or you) | `cloud` API + `web` dashboard |
| **QNX Pi 5** | Connected to friend's PC / SSH | `python3 -m edge.main` |

Both machines `git clone` the same repo: https://github.com/ArpitKhavate/Acre

---

## Part A — Laptop (report server)

Do this first. Find the laptop's LAN IP (same Wi‑Fi the Pi will use):

- **Windows:** `ipconfig` → IPv4 (e.g. `192.168.1.42`)
- **macOS/Linux:** `ip addr` or `ifconfig`

### One-command start (recommended)

**Windows (PowerShell):**
```powershell
cd Acre
.\scripts\start_laptop_demo.ps1
```

**macOS / Linux:**
```sh
cd Acre
sh scripts/start_laptop_demo.sh
```

This starts the API on `0.0.0.0:8000` and the web app on `0.0.0.0:3000`.

### Manual start (two terminals)

**Terminal 1 — API**
```sh
pip install -r requirements.txt -r cloud/requirements.txt
python -m cloud.seed
uvicorn cloud.app.main:app --host 0.0.0.0 --port 8000
```

**Terminal 2 — Web**
```sh
cd web
cp .env.example .env.local
# Edit .env.local:
#   NEXT_PUBLIC_ACRE_API=http://<LAPTOP_IP>:8000
#   NEXT_PUBLIC_ACRE_FARM=demo-farm-1
npm install
npm run dev -- -H 0.0.0.0 -p 3000
```

Open in a browser: **http://\<LAPTOP_IP\>:3000**

Optional: set `ANTHROPIC_API_KEY` on the laptop for a real Claude summary; without it
the report still shows scores, zones, and pesticides (offline fallback text).

---

## Part B — QNX Pi (scanner)

Full hardware wiring: [QNX_SETUP.md](QNX_SETUP.md).

### 1. Clone on the Pi

```sh
git clone https://github.com/ArpitKhavate/Acre.git
cd Acre
```

### 2. Copy ONNX models (not in Git)

Models are gitignored. From the **laptop** (after training or copying artifacts):

```sh
# On laptop, from repo root:
scp -r models/artifacts/*.onnx models/labels/*.json pi@<PI_IP>:~/Acre/models/
```

Or USB stick → `Acre/models/artifacts/` and `Acre/models/labels/` on the Pi.

Files needed on the Pi:
- `models/artifacts/detector.onnx`, `disease.onnx`, `pest.onnx`
- `models/labels/detector.json`, `disease.json`, `pest.json`

If OpenCV/ONNX Runtime are **not** on QNX yet, the LED/LCD/sync loop still runs;
detection stays disabled until those libs are installed (see QNX_SETUP §7a).

### 3. Python deps on QNX (`apk`, not `pip`)

QNX us
[truncated — 3401 more characters]
```

### docs/QNX_SETUP.md

```markdown
# Acre on QNX — Beginner Setup Guide

This is the step-by-step for getting Acre running on a Raspberry Pi 5 with **QNX**.

This build wires everything **directly to the Pi 5** (no Arduino):

- **RGB LED** — green = healthy, red = needs treatment (pest/disease/weed)
- **Laser emitter** — points at the detected target
- **Pan servo** — aims the camera+laser bracket (single axis, PRD 7.1)
- **I2C 1602 LCD** — shows the live `HEALTH NN/100` score + finding
- **Humiture sensor** — optional temp/humidity (disease-risk signal)
- **Camera Module 3** — the only camera (CSI)

> Big-picture: the LED, laser, and servo are the easy parts on QNX (GPIO via
> `gpio-rp1`, hardware PWM via the `rpi_gpio` Python module). The LCD needs the
> I2C driver running (Step 5). The camera and the AI model are the hard parts on
> QNX (see Step 7). Read the whole guide before sinking time into any one step.

---

## What each pin connects to (BCM numbers)

| Part | Pin (BCM) | Physical pin | Notes |
|---|---|---|---|
| LED red | GPIO 17 | 11 | through a 330Ω resistor |
| LED green | GPIO 27 | 13 | through a 330Ω resistor |
| LED blue (optional) | GPIO 23 | 16 | through a 330Ω resistor |
| Laser + | GPIO 24 | 18 | laser minus → GND |
| Servo signal | GPIO 18 | 12 | hardware PWM ch1; servo V+ → 5V, GND → GND |
| LCD SDA | GPIO 2 | 3 | I2C1 data |
| LCD SCL | GPIO 3 | 5 | I2C1 clock |
| Humiture (DHT11) data | GPIO 26 | 37 | single-wire; VCC → 3V3/5V, GND → GND |
| Ground | GND | 6 / 9 / 14 | common ground for all parts |

RGB LED: the longest leg is the common. If common-cathode, it goes to **GND**
(this is what the code assumes). If nothing lights or colors are inverted, your
LED is probably common-anode — flip the logic in `edge/led.py`.

Servo: power a hobby servo from the Pi's 5V (physical pin 4) and GND, signal on
GPIO 18. A larger/stalling servo can brown out the Pi — use a separate 5V supply
with a common ground if it misbehaves.

Camera: ribbon into the Pi's CSI port, power OFF when connecting. On Pi 5 the
metal contacts face the HDMI side.

SAFETY: never point the laser at anyone's eyes.

---

## Step 1 — Flash QNX onto the SD card

Follow QNX's "Quick Start Target Image (QSTI) for Raspberry Pi" guide and flash
the **Raspberry Pi 5** image (`com.qnx.qnx800.quickstart.rpi5`). Boot the Pi,
connect it to Wi-Fi, and get its IP address so you can SSH in.

## Step 2 — Get the code onto the Pi

```sh
git clone https://github.com/ArpitKhavate/Acre.git
cd Acre
```

## Step 3 — Test the LED, laser, and servo (no models needed)

These prove your wiring and QNX GPIO/PWM work before anything else:

```sh
sh scripts/qnx_led_test.sh      # RGB LED cycles red/green/blue
sh scripts/qnx_laser_test.sh    # laser blinks 3x
python3 scripts/qnx_servo_test.py   # servo sweeps + laser blink
```

Run one part at a time. If a part doesn't move/light, fix its wiring now —
nothing downstream matters until each part works on its own.

## Step 4 — Set the clock

QNX has no battery clock here, so se
[truncated — 4645 more characters]
```

### requirements.txt

```
# Acre EDGE (on-device) dependencies — Raspberry Pi 5 / QNX.
# Inference runs fully offline here. Cloud deps live in cloud/requirements.txt;
# off-device training deps live in models/requirements-train.txt.

numpy>=1.26,<3
opencv-contrib-python==4.10.0.84   # includes cv2.dnn + cv2.aruco
onnxruntime==1.19.2                # ONNX inference (falls back to cv2.dnn)
requests==2.32.3                   # sync agent

# Raspberry Pi only (install on the device, not on a dev laptop):
# lgpio==0.2.2.0                   # red/green LED + buzzer on Pi GPIO
# picamera2==0.3.18                # Pi Camera Module 3 capture

```

### cloud/requirements.txt

```
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
pydantic==2.9.2
anthropic==0.34.2
# Postgres/Supabase driver (not needed for the local SQLite default):
psycopg[binary]==3.2.3
requests==2.32.3

# Optional integrations (install only if using these tracks):
# arize==7.19.0        # model monitoring (cloud/integrations/arize_logger.py)
# pandas==2.2.2        # required by the arize logger


```

### web/package.json

```
{
  "name": "acre-web",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3000",
    "build": "next build",
    "start": "next start -p 3000",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "14.2.5",
    "react": "18.3.1",
    "react-dom": "18.3.1"
  },
  "devDependencies": {
    "@types/node": "20.14.10",
    "@types/react": "18.3.3",
    "@types/react-dom": "18.3.0",
    "typescript": "5.5.3"
  }
}

```

### edge/main.py

```python
"""Acre handheld scan loop (PRD sections 3, 7, 9.2).

Per dwell, while the operator holds the unit over a plant:
  capture -> ArUco zone -> motion check -> detect+classify -> health score
  -> drive red/green LED -> log to local SQLite (synced later by sync_agent).

Runs anywhere: real hardware lights real LEDs; a dev laptop prints LED states
and uses synthetic frames if no camera/models are present.

    python -m edge.main              # continuous auto-dwell
    python -m edge.main --once       # single scan, then exit
    python -m edge.main --no-sync    # don't start the background sync thread
"""
from __future__ import annotations

import argparse
import threading
import time

from . import aim, capture, config, detect, health_score, lcd, led, local_db, rtc, sensors, treatments
from .aruco_zones import ZoneResolver
from .sync_agent import flush_and_report, sync_loop

# Findings that warrant pointing the laser and turning the LED red.
_TREATABLE = {"weed", "disease", "pest"}


def treatment_id_for(score_result) -> str | None:
    """Map a finding to a treatments-table key (resolved fully in the cloud)."""
    return treatments.treatment_id_for(score_result.type, score_result.class_name)


def _target_cx(result, frame_width: int) -> float:
    """Horizontal centroid of the thing to aim at (weed box, else plant, else center)."""
    if result.weed_boxes:
        box = max(result.weed_boxes, key=lambda b: b.conf)
    elif result.plant_box is not None:
        box = result.plant_box
    else:
        return frame_width / 2.0
    x, _, w, _ = box.xywh
    return x + w / 2.0


class MotionDetector:
    """OpenCV MOG2 — surfaces moving pests near an otherwise-still plant."""

    def __init__(self, min_area: int = 500):
        self.min_area = min_area
        self._sub = None
        try:
            import cv2

            self._sub = cv2.createBackgroundSubtractorMOG2(detectShadows=False)
        except Exception:
            self._sub = None

    def detect(self, frame) -> bool:
        if self._sub is None:
            return False
        import cv2

        mask = self._sub.apply(frame)
        _, mask = cv2.threshold(mask, 200, 255, cv2.THRESH_BINARY)
        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        return any(cv2.contourArea(c) > self.min_area for c in contours)


def scan_once(cam, detector, zones, motion, conn, sensor_unit=None) -> dict | None:
    frame = cam.read()
    zone = zones.detect_zone(frame)
    zone_id = zone["zone_id"] if zone else "unknown"

    moved = motion.detect(frame)
    result = detector.analyze(frame, motion=moved)

    # Optional environmental signal feeds the sensor-anomaly score term.
    anomalies = 0
    if sensor_unit is not None and sensor_unit.backend != "disabled":
        reading = sensor_unit.read()
        anomalies = reading.anomaly_count()
        local_db.insert_sensor(
            conn, zone_id=zone_id, temperature_c=reading.temperature_c,
            humidity_pct=reading.humidity_pct, gas_raw=reading.gas_raw,
        )

    score = health_score.compute(result, sensor_anomaly_count=anomalies)

    if score is None:
        # No plants or weeds detected. Stand down.
        lcd.show("Scanning...", zone_id)
        aim.laser("OFF")
        aim.set_angle(config.SERVO_CENTER_ANGLE)
        return {"zone": zone_id, "type": "none"}

    # RGB LED: green = healthy, red = needs treatment. Buzzer only if wired.
    state = led.signal_for_score(score.score, buzz_on_red=config.BUZZER_ENABLED)

    # On-device display: zone + 0-100 health score + the finding (PRD 6).
    finding = "healthy" if score.type == "healthy" else f"{score.type}:{score.class_name}"
    if score.pest_class:
        finding += f" pest:{score.pest_class}"
    lcd.show_score(zone_id, score.score, finding)

    # Perception -> action: center the pan servo on the target and hold the
    # laser on it while the operator dwells (PRD 3, 7.1). Otherwise stand down.
    if score.type in _TREATABLE:
        frame_w = frame.shape[1]

        def fresh_target_cx():
            # Re-measure after each servo move (camera shares the pan axis).
            res = detector.analyze(cam.read(), motion=False)
            if res.weed_boxes or res.plant_box is not None:
                return _target_cx(res, frame_w)
            return None

        locked = aim.center_on(_target_cx(result, frame_w), frame_w,
                               get_target_cx=fresh_target_cx)
        aim.laser("ON")
        print(f"[aim] {zone_id} target {score.type}:{score.class_name} "
              f"locked={locked}")
    else:
        aim.laser("OFF")
        aim.set_angle(config.SERVO_CENTER_ANGLE)

    bbox = result.plant_box.xywh if result.plant_box else None
    rec_uuid = local_db.insert_detection(
        conn,
        zone_id=zone_id,
        type=score.type,
        class_name=score.class_name,
        crop_type=score.crop_type,
        confidence=score.confidence,
        health_score=score.score,
        led_state=state,
        bbox=bbox,
        treatment_id=treatment_id_for(score),
    )
    summary = {
        "uuid": rec_uuid,
        "zone": zone_id,
        "type": score.type,
        "class": score.class_name,
        "score": score.score,
        "led": state,
    }
    print(f"[scan] {zone_id}  {score.type}:{score.class_name}  "
          f"score={score.score}  LED={state}")
    return summary


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--once", action="store_true", help="single scan then exit")
    ap.add_argument("--no-sync", action="store_true", help="skip background sync")
    ap.add_argument("--interval", type=float, default=2.0, help="dwell seconds")
    args = ap.parse_args()

    print(f"[acre] device={config.DEVICE_ID} clock={rtc.source()} led={led.backend_name()}")
    print(f"[acre] lcd backend={lcd.backend_name()}  aim {aim.backend_name()}")
    lcd.show("ACRE booting", "")
    cam = capture.Camera()
    print(f"[acr
[truncated — 1223 more characters]
```

### web/app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Acre — Field Health Dashboard",
  description: "Offline-first handheld plant scanner. Map, scores, and AI report.",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

```

### web/app/page.tsx

```typescript
import { api, FarmMap as FarmMapData, Report, TreatmentRow } from "@/lib/api";
import { FarmMap } from "@/components/FarmMap";
import { TreatmentTable } from "@/components/TreatmentTable";
import { SummaryCard } from "@/components/SummaryCard";

const FARM_ID = process.env.NEXT_PUBLIC_ACRE_FARM ?? "demo-farm-1";

export default async function Page() {
  let map: FarmMapData | null = null;
  let treatments: TreatmentRow[] = [];
  let report: Report = null;
  let apiError: string | null = null;

  try {
    map = await api.farmMap(FARM_ID);
    treatments = (await api.treatments(FARM_ID)).rows;
    report = (await api.latestReport(FARM_ID)).report;
  } catch (e) {
    apiError = String(e);
  }

  return (
    <div className="wrap">
      <header className="app">
        <h1>Acre</h1>
        <span className="tag">offline-first handheld plant scanner</span>
      </header>
      <p className="subtitle">
        Farm <strong>{FARM_ID}</strong> · scores computed on-device, the red/green
        LED is the spray signal · this dashboard is the cloud report.
      </p>

      {apiError && (
        <div className="card" style={{ marginBottom: 20 }}>
          <p className="error">Could not reach the Acre API ({apiError}).</p>
          <p className="muted">
            Start it with <code>uvicorn cloud.app.main:app --port 8000</code> and
            seed via <code>python -m cloud.seed</code>.
          </p>
        </div>
      )}

      <div className="grid">
        <div className="card">
          <h2>Farm map</h2>
          {map ? <FarmMap zones={map.zones} /> : <p className="muted">No map data.</p>}
        </div>
        <SummaryCard farmId={FARM_ID} initial={report} farmScore={map?.farm_score ?? null} />
      </div>

      <div className="card" style={{ marginTop: 20 }}>
        <h2>Treatments needed by zone</h2>
        <TreatmentTable rows={treatments} />
      </div>
    </div>
  );
}

```

### cloud/app/main.py

```python
"""Acre cloud API (FastAPI) — reporting only, no inference (PRD sections 8.6, 9, 10).

Endpoints:
  POST /api/sync                          idempotent ingest from edge devices
  GET  /api/farms/{farm_id}/map           zones + latest health color for the map
  GET  /api/farms/{farm_id}/treatments    pesticide summary grouped by zone
  POST /api/farms/{farm_id}/report        recompute scores + Claude AI summary
  GET  /api/farms/{farm_id}/report/latest most recent stored report
  GET  /health
"""
from __future__ import annotations

import uuid
from datetime import datetime, timezone

from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session

from . import aggregate, ai_summary, models
from .db import get_db, init_db
from .schemas import SyncRequest, SyncResponse

app = FastAPI(title="Acre Cloud API", version="0.1.0")
app.add_middleware(
    CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)


@app.on_event("startup")
def _startup():
    init_db()


@app.get("/health")
def health():
    return {"status": "ok", "service": "acre-cloud"}


@app.post("/api/sync", response_model=SyncResponse)
def sync(payload: SyncRequest, db: Session = Depends(get_db)):
    """Idempotent upsert by uuid (PRD 8.6). Retried batches never double-count."""
    synced = 0
    dupes = 0

    _ensure_device(db, payload.device_id)

    for d in payload.records.detections:
        if db.get(models.Detection, d.uuid):
            dupes += 1
            continue
        db.add(models.Detection(
            id=d.uuid,
            device_id=payload.device_id,
            zone_id=d.zone_id,
            captured_at=d.captured_at,
            type=d.type,
            class_name=d.class_name,
            crop_type=d.crop_type,
            confidence=d.confidence,
            health_score=d.health_score,
            led_state=d.led_state,
            bbox=d.bbox.model_dump() if d.bbox else None,
            treatment_id=d.treatment_id,
            image_url=d.image_url,
        ))
        synced += 1

    for s in payload.records.sensor_readings:
        if db.get(models.SensorReading, s.uuid):
            dupes += 1
            continue
        db.add(models.SensorReading(
            id=s.uuid,
            device_id=payload.device_id,
            zone_id=s.zone_id,
            captured_at=s.captured_at,
            temperature_c=s.temperature_c,
            humidity_pct=s.humidity_pct,
            gas_raw=s.gas_raw,
        ))
        synced += 1

    dev = db.get(models.Device, payload.device_id)
    if dev:
        dev.last_synced_at = datetime.now(timezone.utc)
    db.commit()
    return SyncResponse(synced=synced, duplicates_ignored=dupes)


@app.get("/api/farms/{farm_id}/map")
def farm_map(farm_id: str, db: Session = Depends(get_db)):
    zones = db.query(models.Zone).filter(models.Zone.farm_id == farm_id).all()
    scores = aggregate.latest_zone_scores(db, farm_id)
    out_zones = []
    for z in zones:
        s = scores.get(z.id)
        out_zones.append({
            "zone_id": z.id,
            "crop_type": z.crop_type,
            "map_x": z.map_x,
            "map_y": z.map_y,
            "score": s["score"] if s else None,
            "factors": s["factors"] if s else None,
            "color": _color(s["score"]) if s else "gray",
        })
    farm = scores.get("__farm__")
    return {
        "farm_id": farm_id,
        "farm_score": farm["score"] if farm else None,
        "zones": out_zones,
    }


@app.get("/api/farms/{farm_id}/treatments")
def treatments_summary(farm_id: str, db: Session = Depends(get_db)):
    """Pesticide table grouped by zone (PRD section 10.2)."""
    zone_ids = [z.id for z in db.query(models.Zone)
                .filter(models.Zone.farm_id == farm_id).all()]
    rows = (
        db.query(models.Detection)
        .filter(models.Detection.zone_id.in_(zone_ids),
                models.Detection.type != "healthy")
        .all()
    )
    grouped: dict = {}
    for r in rows:
        key = (r.zone_id, r.class_name)
        g = grouped.setdefault(key, {"zone_id": r.zone_id, "class_name": r.class_name,
                                     "count": 0, "max_conf": 0.0, "treatment_id": r.treatment_id})
        g["count"] += 1
        g["max_conf"] = max(g["max_conf"], r.confidence)

    out = []
    for g in grouped.values():
        t = db.get(models.Treatment, g["treatment_id"]) if g["treatment_id"] else None
        out.append({**g, "treatment": _treatment_dict(t)})
    return {"farm_id": farm_id, "rows": out}


@app.post("/api/farms/{farm_id}/report")
def generate_report(farm_id: str, period: str = "daily", db: Session = Depends(get_db)):
    roll = aggregate.recompute_scores(db, farm_id)
    treatments = treatments_summary(farm_id, db)
    worst = sorted(
        ((zid, s) for zid, (s, _) in roll["zones"].items()),
        key=lambda kv: kv[1],
    )[:3]
    period_data = {
        "farm_score": roll["farm_score"],
        "worst_zones": [zid for zid, _ in worst if _ < 80],
        "treatments_needed": list({r["class_name"] for r in treatments["rows"]}),
        "zone_scores": {zid: s for zid, (s, _) in roll["zones"].items()},
    }
    summary = ai_summary.generate_summary(period_data)

    report = models.Report(
        id=str(uuid.uuid4()),
        farm_id=farm_id,
        period=period,
        health_score=roll["farm_score"],
        ai_summary=summary,
    )
    db.add(report)
    db.commit()
    return {
        "farm_id": farm_id,
        "health_score": roll["farm_score"],
        "ai_summary": summary,
        "period_data": period_data,
    }


@app.get("/api/farms/{farm_id}/report/latest")
def latest_report(farm_id: str, db: Session = Depends(get_db)):
    r = (
        db.query(models.Report)
        .filter(models.Report.farm_id == farm_id)
        .order_by(models.Report.generated_at.desc())
        .first()
    )
    if not r:
        return {"farm_id": farm_id, "report": None}
[truncated — 856 more characters]
```

### cloud/__init__.py

```python
"""Acre cloud package (reporting-only backend)."""

```

[56 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]