ADR 0030 Mowing Patterns And Planner - lukislp/mowdeck GitHub Wiki

ADR 0030: Mowing patterns as plug-ins and the Planner

Status

Accepted (2026-10-10); amended by ADR 0043 (2026-10-15): on request the lane ends of stripe patterns are shaped as arcs, bulb turns or three-point turns (reverse segments), clipped to the area and the spot run's geofence.

Context

Phase 5 ("Spot & Kamera") brings spot mowing of any shape and re-mowing missed patches (project plan, "Mähmuster & Routen", "Spot-Mähen beliebiger Formen", "Verpasste Flecken nachmähen"). Patterns are plug-ins: input a polygon with holes, mower data and settings that describe themselves as JSON Schema; output a neutral mowing plan of segments (drive with the blades on or off at a speed, in order) that does not depend on the mower model. The same validation chain applies to every pattern (inside the area with a safety margin, the turning radius is feasible, no no-go zones, blades off on transfers), so a plug-in cannot produce an unsafe route. Patterns can be chained ("2 border laps + diagonal + re-mow") and saved as templates. The Planner computes, checks and previews (CPU-bound, "no own data", reusable as a library). Execution goes through the Device-Gateway: as the mower's own path sequence, or as a native job when the mower runs the pattern itself. The LUBA runs stripes at any angle, checkerboard, border laps and the order of both natively, but no arbitrary paths are known (spike 0001, section 3.2); MowerCapabilities.custom_paths is False for it, and mowdeck_control.translation.plan_to_native_job is the seam to native jobs (ADR 0012).

Decision

mowdeck-patterns is a pure library (Apache-2.0) on Shapely 2.1. No network, no database; it depends on mowdeck-contracts and shapely>=2.1 only. All geometry is local metres of the mower's map (MowerMap, ADR 0023). The models: Area (boundary, holes, no-go zones, obstacles; at most 5000 vertices, 50 000 m² and 1 km across), MowerSpec (cutting width, overlap, turning radius, mowing and transfer speed, safety margin, time per turn), MowPlan (ordered Segment{polyline, blades_on, speed, kind} with kind mow, turn or transfer, estimated_duration_s, covered_area_m2, area_m2, pattern_chain, warnings). The spacing of runs is the cutting width minus the overlap; while cutting, the mower's centre keeps half the cutting width (at least the safety margin) from every edge, hole and zone, so the blade reaches the edge without the mower leaving the margin.

A pattern is a class with key, name, settings_schema() and compute(area, mower, settings) (the MowPattern protocol), registered as an entry point in the group mowdeck.patterns (name = key). The built-ins derive from BasePattern: settings are a Pydantic model (its JSON Schema is the form the app builds, the same model validates and refuses unknown keys), the pattern only orders runs, and the shared code joins runs with turns (short, inside the cutting region, blades on) or routed transfers (blades off, shortest way inside the safe region along a visibility graph of its corners), then estimates duration (driving time plus a fixed time per turn above 60 degrees) and coverage (blades-on segments widened to the cutting width, intersected with the mowable region). Built-in: stripes, diagonal, checkerboard, spiral (concentric around the outer shape, cut at holes), border_fill (laps, then any built-in fill), contour (laps parallel to every edge including holes), auto_aligned (the angle with the fewest runs among 5 degree steps and the longest edges), cells (slab decomposition through concave corners and hole corners, each cell at its own best angle), alternating (angle from a rotation list by run_index, or a random angle deterministic for seed and run_index), edge_only and remow (stripes over gap polygons, or over the area minus a covered polygon). Waves, circles, rays and lawn art are later. chain() runs patterns one after the other with blades-off transfers between them (at most eight).

One validation chain for every plan. validate(plan, area, mower) raises PlanRejected(reasons): every segment inside the area minus the safety margin (holes and zones excluded); no blades-on segment through a no-go zone, an obstacle or a hole; transfers with the blades off; speeds within the mower's limits; and a turning heuristic: a heading change of theta needs r * tan(theta / 2) of straight track on both sides of the corner, a reversal is impossible for r > 0 (mowers that turn on the spot use r = 0). plan() is compute plus validate and is what the Planner calls; a plug-in's output never reaches a mower unchecked. coverage(track, width) and gaps(area, covered, min_area) turn a position track into covered ground and the gaps left (slivers below 5 cm are opened away), preview(plan) gives a GeoJSON-like collection.

Plans travel inline in the start command, not by reference. The contract SpotMowPlan{plan_hash, pattern_chain, segments, estimated_duration_s, covered_area_m2, native} carries SpotSegment{kind, blades_on, speed_mps, path} where path is an encoded polyline (the map-services algorithm, centimetre precision): a plan of a few thousand points stays well below the bus message size. plan_hash is the SHA-256 of the canonical JSON of chain and segments and must match, so a recorded hash identifies exactly one route. StartMow gains the optional spot_plan as a third target (exactly one of area_ids, plan_ref, spot_plan; old messages stay valid). The alternative, a plan store with plan_ref, would give the Planner data of its own and every consumer a dependency on it; inline plans need nothing but the contract.

Execution decides by capabilities. MowerController accepts a spot_plan when capabilities.custom_paths is true; otherwise only when the plan carries native (SpotNativeJob{area_ids, pattern}: the chain is one native pattern, or border_fill with a native fill and at most four laps, on a whole map area that the mower supports), and then sends the native StartMow(area_ids, pattern) instead, checked like any area start. Anything else is rejected ("custom paths not supported ..."). The simulator now reports custom_paths=True (option custom_paths=False simulates a LUBA-like mower) and follows the segments with their blades and speed; the Mammotion adapter keeps custom_paths=False and rejects a spot plan without a native job itself as well. The Device-Gateway passes the command through unchanged.

Fleet records the hash, gates the feature, limits the size. A start_mow with a spot_plan needs the plan feature spot_mowing (declared by the Planner, granted to home and fleet, unlimited has *) besides control, may be at most MOWDECK_SPOT_PLAN_MAX_BYTES (256 KiB) as JSON, is refused for bulk commands (a plan is computed for one mower's map), and its record keeps plan_hash (new column, CommandView.plan_hash) and a summary instead of the segments, so the command audit stays small.

The Planner service (services/planner, AGPL) is a thin HTTP layer: GET /patterns (registry with schemas and the native hint), POST /plans/preview (mower or raw map, a map area and/or a spot polygon in local metres or WGS84, a chain or a saved template, optional mower data overrides; answers totals, warnings, the preview and how the mower can run it: custom_paths, native, unsupported or unknown), POST /plans (the same plus the SpotMowPlan and the ready StartMow for Fleet; nothing is stored, the same request gives the same hash), POST /coverage/gaps and templates. It keeps a directory of map and capability snapshots from the DEVICE stream (durable consumer, newest message per subject first, like the Scheduler and Telemetry), converts WGS84 with the map's geo reference, and refuses drawn spots outside the mapped areas (the mower knows nothing beyond them). Geometry runs in worker threads, at most MOWDECK_PLANNER_MAX_PARALLEL per replica. Previews need can_view on the mower, plans can_control (Fleet checks again), every planning endpoint the plan feature spot_mowing; planner.view is an account key implied by mower.view.

Coverage over the bus. Services do not call each other over HTTP, and subscribing to every position would be heavy, so Telemetry answers a core NATS request PositionTrackRequest{account_id, mower_id, start, end, limit} with PositionTrackReply{count, track, first_at, last_at, truncated, error} (the track an encoded polyline of latitude and longitude, seven decimals, up to 50 000 points). The subject is tracks.positions.query, deliberately not telemetry.positions.query: the TELEMETRY stream captures telemetry.>, and a request on a stream subject is answered by JetStream's publish acknowledgement instead of the responder. The track has no blade state; everything driven in the chosen time range counts as covered, jumps over 3 m break the track.

Deviation from "no own data": templates. Templates are per account and must survive restarts, so the Planner owns a small PostgreSQL database (templates, plus the snapshot directory mower_facts) instead of storing JSON in Fleet, which would couple a planning concept to the mower registry. Plans themselves are still never stored. Templates hold only pattern keys and settings; every principal of the account reads them, saving and deleting needs can_control on the account.

Consequences

  • A new pattern is a class plus an entry point; the Planner, the app (form from the schema) and the validation chain need no change. Gating individual patterns by plan stays possible (one feature key per pattern) but is not done yet; spot_mowing gates them all.
  • Mowers without custom_paths (the LUBA today) run only whole-area native plans; spot polygons, spirals, contours, cells and re-mowing need a mower or adapter that follows paths. The LUBA's path following remains an open question of the spike.
  • Coverage counts transfers as mowed (no blade state in the position track); choose the job's time range. A blade flag in the track would make it exact.
  • Plans are computed for the map at request time; a map changed in the vendor app between preview and start is not detected by the hash. The simulator and the controller check segments only for blades-off transfers, the Planner's validation chain is the safety check.
  • Coordinates of spot plans are rounded to centimetres. Patterns keep an extra centimetre from the margins, and the Planner runs the validation chain on the plan before and after rounding.

Amendment (2026-10-14): mowing defaults per mower in Fleet

Decision. The settings of a native start that every client offers are one contract, MowPattern{name, angle_deg, border_laps, spacing_m} (the common, validated part of StartMow.pattern; adapters may accept further keys), and Fleet keeps them per mower as the mowing defaults: PUT /api/v1/fleet/mowers/{id}/mow-defaults with {"pattern": MowPattern | null} (can_control, 404 semantics; unknown keys and values out of range are 422; null drops the defaults), MowerOut.mow_defaults ({"pattern": {...}} or null) on every mower answer, the audit action mower.mow_defaults_changed only when the value changes, and TargetMower.mow_pattern in the reply of fleet.targets.resolve, so the Scheduler starts from the same defaults as the web start sheet and Home Assistant. Without saved defaults every client uses stripes at 0 degrees with one border lap. Nothing is sent to the mower: a start still names its own pattern, and whether the mower runs it is decided with MowerCapabilities.native_patterns. Inside Fleet the defaults are the domain value MowDefaults{pattern: MowPattern}; the stored shape (mowers.mow_defaults, nullable JSONB {"pattern": {...}} without unset optional keys, migration 0009) belongs to the database adapter alone, and a row this version cannot read counts as "no defaults".

Alternatives: keeping the settings in the browser would lose them across devices and would leave Home Assistant and the Scheduler without them. Planner templates are account-wide pattern chains for spot plans, not the native start settings of one mower, and the Scheduler would then depend on the Planner to issue a start. A setpoint sent to the mower does not exist: the LUBA takes the pattern per job, not as a persistent setting. One pattern per mower rather than per area keeps the start sheet and the schedule form small; per-area defaults can be added as a further key later.

Consequences: a pattern the mower does not support can be saved (the defaults describe what the user wants; the start sheet offers only what native_patterns reports and the start is checked by the controller). spacing_m stays null unless the user sets it, so the model's own spacing remains the default. The schedule form still edits only the angle and the rotation list; the other settings come from the defaults.


Mirrored from docs/adr/0030-mowing-patterns-and-planner.md at commit 2c95bd2e71e632d0ce48646e30681257807fac8c. Edit the file in the repository, not this wiki page.