# Kite-Flyability Scoring Rubric **Document:** 05 — Flyability Scoring **Status:** Approved for Milestone 1 **Depends on:** `docs/02-feature-spec.md` (F-04 Flyability Score), `docs/04-data-sources.md` (Open-Meteo / NOAA fields) --- ## 1. Purpose & Principles The flyability score answers one question at a glance: **"Should I bother driving to this spot with this kite, right now (or at hour X)?"** Design principles: 1. **Deterministic and open.** The formula is published here and versioned. Anyone can recompute a score from the same inputs and get the same answer. No ML black box in v1. 2. **Kite-type aware.** A 35 km/h wind is a great day for a kitesurfer and a kite-shredding day for a child's single-line diamond. One score per kite-type profile, not one global score. 3. **Hard safety gates before soft scoring.** Thunderstorm risk, power lines, and airport proximity are not "minus points" — they force the score to zero or cap it, with an explicit reason string. 4. **Explainable output.** The API never returns a bare number; it returns the number plus the component breakdown and any gate reasons (see §8), so the UI can show *why*. Scores are integers **0–100**, computed per `(spot, kite_profile, forecast_hour)`. | Band | Range | Label | UI color token | |---|---|---|---| | A | 80–100 | Excellent | `score-excellent` (green) | | B | 60–79 | Good | `score-good` (light green) | | C | 40–59 | Fair | `score-fair` (yellow) | | D | 20–39 | Poor | `score-poor` (orange) | | E | 1–19 | Not recommended | `score-bad` (red) | | — | 0 | No-fly (gated) | `score-nofly` (red, slash icon) | --- ## 2. Inputs All wind values are canonically stored in **m/s at 10 m above ground** (Open-Meteo `wind_speed_10m`, `wind_gusts_10m`, `wind_direction_10m`). Display conversion (km/h, mph, knots) is a presentation concern only. | Symbol | Source | Description | |---|---|---| | `W` | forecast | Sustained wind speed 10 m, m/s | | `G` | forecast | Wind gust speed 10 m, m/s | | `D` | forecast | Wind direction, degrees (meteorological: direction wind comes FROM) | | `P` | forecast | Precipitation rate, mm/h | | `Tprob` | forecast | Thunderstorm proxy: precipitation probability ≥ 60% **and** CAPE ≥ 1000 J/kg, or provider `weather_code` ∈ {95, 96, 99} | | `T` | forecast | Air temperature 2 m, °C (comfort modifier only) | | `S.obstacles` | spot record | Obstacle tags (§6) | | `S.sectors` | spot record | Good-wind direction sectors (§5) | | `S.water` | spot record | Boolean: launch is on/over water (kitesurf spots) | | `S.shore_bearing` | spot record | For water spots: compass bearing pointing from shore out to open water | --- ## 3. Kite-Type Wind Bands Four built-in profiles (extensible via `scoring_profiles` table, see `docs/06-database-schema.md`). Each profile defines a trapezoid: `(w_min, w_ideal_lo, w_ideal_hi, w_max)` in m/s. | Profile key | Audience | w_min | w_ideal_lo | w_ideal_hi | w_max | Notes | |---|---|---|---|---|---|---| | `single_line` | Casual flyers, kids, deltas, diamonds, box kites | 1.7 (≈6 km/h) | 2.8 (≈10) | 5.5 (≈20) | 8.3 (≈30) | Light fabric kites overpower fast | | `sport_dual` | Dual/quad-line stunt kites | 2.8 (≈10) | 4.2 (≈15) | 8.3 (≈30) | 11.1 (≈40) | Wide usable band; precision suffers in gusts | | `power_traction` | Foils, landboarding, buggying | 3.3 (≈12) | 5.0 (≈18) | 9.7 (≈35) | 12.5 (≈45) | Riders size kites to wind; band reflects "some kite in the quiver works" | | `kitesurf` | Kite surfers / wing-adjacent | 5.1 (≈10 kn) | 6.2 (≈12 kn) | 12.9 (≈25 kn) | 15.4 (≈30 kn) | Below 10 kn nothing planes; above 30 kn only experts, we cap rather than zero | ### Wind component `score_wind(W)` — trapezoid, 0–100 ``` score_wind(W): if W < w_min or W > w_max: return 0 if w_ideal_lo <= W <= w_ideal_hi: return 100 if W < w_ideal_lo: return 100 * (W - w_min) / (w_ideal_lo - w_min) else: return 100 * (w_max - W) / (w_max - w_ideal_hi) ``` Above `w_max` for `kitesurf` only: instead of 0, return a fixed 15 with reason `"wind_above_max_experts_only"` up to `1.2 × w_max`, then 0. (Strong-wind kitesurfing is a real, if niche, use case; misleading the casual majority is worse, hence the low cap.) --- ## 4. Gust Penalty Gustiness, not raw speed, is what folds kites and slams kitesurfers. We use the **gust ratio** `R = G / max(W, 0.5)` (floor prevents divide-by-near-zero in calm conditions). | R | Multiplier `m_gust` | Interpretation | |---|---|---| | ≤ 1.30 | 1.00 | Smooth, laminar | | 1.30 – 1.60 | linear 1.00 → 0.80 | Noticeable gusts | | 1.60 – 2.00 | linear 0.80 → 0.55 | Rough; hard work | | > 2.00 | 0.40 | Squally / dangerous turbulence | Additional **absolute-gust gate**: if `G > 1.15 × w_max` for the profile, cap the final score at **25** with reason `"gusts_exceed_kite_limit"` — even if sustained wind is in band, the peaks will break gear. Per-profile sensitivity: `single_line` and `kitesurf` use the table as-is; `sport_dual` and `power_traction` pilots actively depower, so their `m_gust` is `min(1.0, m_gust + 0.10)`. ``` m_gust(R, profile): base = piecewise(R) # table above if profile in {sport_dual, power_traction}: base = min(1.0, base + 0.10) return base ``` --- ## 5. Wind-Direction Suitability Each spot stores zero or more **good sectors** `(from_deg, to_deg)` (clockwise, may wrap 360°). Empty list ⇒ direction-agnostic spot (e.g., open hilltop), `m_dir = 1.0`. ``` m_dir(D, sectors): if sectors is empty: return 1.0 if D inside any sector: return 1.0 delta = min angular distance from D to nearest sector edge if delta <= 20°: return 0.85 # workable, slightly off if delta <= 45°: return 0.60 else: return 0.35 ``` ### Kitesurf offshore-wind safety gate For spots with `S.water = true`, classify the wind relative to `S.shore_bearing` (the bearing from beach toward open water). Let `θ = angular_difference(D, S.shore_bearing + 180°)` — i.e., how close the wind's *origin* is to land-behind-you. | Classification | Condition | Effect (kitesurf profile only) | |---|---|---| | Onshore / side-onshore | θ ≤ 67.5° | no change | | Cross-shore | 67.5° < θ ≤ 112.5° | no change (ideal, actually) | | Side-offshore | 112.5° < θ ≤ 145° | multiply by 0.6, reason `"side_offshore_wind"` | | **Offshore** | θ > 145° | **cap score at 10**, reason `"offshore_wind_danger"` | Rationale: offshore wind blows a downed kitesurfer out to sea. This is the single most common fatal-incident pattern; it must dominate the score regardless of how perfect the speed is. --- ## 6. Precipitation & Thunderstorm | Condition | Effect | |---|---| | `Tprob` true (thunderstorm proxy, §2) | **Score = 0**, reason `"thunderstorm_risk"`. Non-negotiable: kite line + lightning kills. Applied in a ±1 hour window around any flagged hour. | | `P = 0` | `m_precip = 1.00` | | `0 < P < 0.5` (drizzle) | `m_precip = 0.90` | | `0.5 ≤ P < 2.5` (light rain) | `m_precip = 0.70` — wet lines, soaked fabric | | `2.5 ≤ P < 7.5` (moderate) | `m_precip = 0.40` | | `P ≥ 7.5` (heavy) | `m_precip = 0.15` | Kitesurf profile uses a gentler curve (riders are wet anyway): `m_precip_kitesurf = max(m_precip, 0.6)` — *unless* the thunderstorm gate fired, which always zeroes. --- ## 7. Spot Obstacle Factor Obstacles are **static spot attributes** (curated + OSM-derived, see `docs/04-data-sources.md` §6), not forecast-dependent. Each spot record carries obstacle tags; the worst applicable rule wins for gates, penalties multiply for soft tags. ### Hard gates | Tag | Rule | Effect | |---|---|---| | `power_lines_within_100m` | overhead lines within 100 m of launch area | **Score = 0**, reason `"power_lines"` | | `airport_within_5km` | inside 5 km of an aerodrome reference point (most jurisdictions restrict kites near airfields) | **Cap score at 20**, reason `"near_airport_check_regulations"` (capped, not zeroed: some fields have negotiated permission) | | `no_kite_bylaw` | curated flag: flying prohibited | **Score = 0**, reason `"prohibited_area"` | ### Soft penalties (`m_obstacle`, multiply together, floor 0.5) | Tag | Multiplier | Rationale | |---|---|---| | `tree_line_upwind` | 0.85 | Trees within ~150 m upwind create turbulent shadow ≈ 7–10× tree height downwind | | `buildings_adjacent` | 0.85 | Urban turbulence, snag risk | | `crowded_area` | 0.90 | Bystander risk shrinks usable window | | `uneven_ground` | 0.95 | Launch/landing footing (matters most for traction) | | `shallow_water_hazard` / `rocks` | 0.85 (kitesurf only) | Body-drag and crash hazard | `m_obstacle = max(0.5, Π multipliers_of_present_tags)`. Tags are direction-aware where data permits: `tree_line_upwind` only applies when current wind direction `D` is within ±45° of the stored obstacle bearing; otherwise it is ignored for that hour. --- ## 8. Composite Formula ``` flyability(spot, profile, hour): # --- hard gates, in priority order --- if thunderstorm(hour ± 1h): return Result(0, gate="thunderstorm_risk") if spot.no_kite_bylaw: return Result(0, gate="prohibited_area") if spot.power_lines_within_100m: return Result(0, gate="power_lines") # --- components --- s = score_wind(W, profile) # 0..100 s *= m_gust(G / max(W, 0.5), profile) # 0.40..1.00 s *= m_dir(D, spot.sectors) # 0.35..1.00 s *= m_precip(P, profile) # 0.15..1.00 s *= m_obstacle(spot, D) # 0.50..1.00 # --- caps --- caps = [] if G > 1.15 * profile.w_max: caps.append((25, "gusts_exceed_kite_limit")) if spot.airport_within_5km: caps.append((20, "near_airport_check_regulations")) if profile == kitesurf and offshore(D): caps.append((10, "offshore_wind_danger")) if profile == kitesurf and W > w_max: s = max(s, 0); caps.append((15, "wind_above_max_experts_only")) if W <= 1.2*w_max else caps.append((0, "wind_above_max")) for (cap, reason) in caps: s = min(s, cap) return Result(round(s), components={wind, gust, direction, precip, obstacle}, caps=caps) ``` Temperature is **not** scored (a cold great-wind day is still a great-wind day) but is surfaced in the breakdown as advisory text when `T < 0 °C` (`"freezing_conditions"`) or `T > 35 °C`. ### API response shape (consumed by forecast panel, see `api/openapi.yaml`) ```json { "spot_id": "…", "profile": "sport_dual", "hour": "2025-06-14T14:00:00Z", "score": 72, "band": "good", "components": { "wind": 100, "gust_multiplier": 0.90, "direction_multiplier": 1.0, "precip_multiplier": 0.9, "obstacle_multiplier": 0.9 }, "gates": [], "caps": [], "advisories": [], "rubric_version": "1.0.0" } ``` --- ## 9. Worked Examples **Example 1 — family afternoon, `single_line`.** W = 4.0 m/s, G = 4.8 (R = 1.20), D in sector, P = 0, open park with `tree_line_upwind` but wind from the other side. → wind = 100 (in ideal band 2.8–5.5), m_gust = 1.0, m_dir = 1.0, m_precip = 1.0, m_obstacle = 1.0 (directional tag inactive). **Score 100 — Excellent.** **Example 2 — gusty front, `sport_dual`.** W = 7.0, G = 13.0 (R = 1.86 → base 0.62 + 0.10 = 0.72), light rain P = 1.2 (0.70), direction 30° off sector (0.85), `buildings_adjacent` (0.85). → 100 × 0.72 × 0.85 × 0.70 × 0.85 = **36 — Poor.** Gust check: 13.0 > 1.15 × 11.1 = 12.8 → cap 25 applies. **Final: 25 — Poor**, cap reason shown. **Example 3 — kitesurf, offshore trap.** W = 9.0 m/s (≈17.5 kn, ideal band), G = 10.5 (R = 1.17), dry, but wind blows from land straight out (θ = 170°). → raw = 100, offshore gate caps at **10 — Not recommended**, reason `"offshore_wind_danger"` rendered with warning copy in the UI. **Example 4 — thunderstorm.** Any inputs + `weather_code 95` at 15:00 → hours 14:00–16:00 all return **0**, gate `"thunderstorm_risk"`. --- ## 10. Versioning & Calibration - The rubric carries a semver `rubric_version` (`1.0.0` here), stored with every cached score and returned in API responses. Constants live in the `scoring_profiles` table seed data, **not** hard-coded, so community calibration PRs change data, not code. - Post-launch calibration loop: session reports (`F-09` in the feature spec) include "how was it really?" (1–5). Milestone 3+ compares predicted band vs. reported experience to tune trapezoid edges per profile. Until then, bands derive from published kite-manufacturer wind ranges and kitesurf-school safety guidance. - Any change to a hard gate (§5–§7) requires a maintainer-majority decision per `CHARTER.md` — safety gates are governance-protected.