# Flyability Scoring — Worked Examples & Acceptance Test Vectors This document pins the rubric defined in `docs/05-flyability-scoring.md` to **exact numeric outcomes**. The implementation milestone MUST reproduce these results; the machine-readable form lives in `examples/scoring-fixtures.json` and is the contract for the scoring engine's test suite. If any constant here ever disagrees with `docs/05`, the discrepancy must be resolved by a PR updating both files together and bumping `scoring_version`. ## 1. Constants used in these examples (rubric v1.0) **Component weights** (sum = 1.0): | Component | Weight | |---|---| | Wind speed (sustained, 10 m) | 0.50 | | Gust ratio | 0.20 | | Precipitation | 0.15 | | Wind direction vs. spot sectors | 0.10 | | Temperature comfort | 0.05 | **Wind bands per kite type** (m/s, sustained). Trapezoid scoring: 100 inside the ideal band; linear ramp from 100→0 between ideal edge and flyable edge; 0 outside flyable. | Kite type | Flyable min | Ideal min | Ideal max | Flyable max | |---|---|---|---|---| | `single_line` (casual) | 1.5 | 2.5 | 7.0 | 9.0 | | `dual_line_sport` | 2.5 | 4.0 | 9.0 | 12.0 | | `power_foil` | 4.0 | 5.0 | 11.0 | 14.0 | | `kitesurf_12m` | 5.0 | 6.0 | 12.0 | 15.0 | **Gust ratio** G = gust / sustained (both m/s): G ≤ 1.3 → 100; 1.3 < G ≤ 1.6 → linear 100→50; 1.6 < G ≤ 2.0 → linear 50→0; G > 2.0 → 0. If sustained < 2.0 m/s the ratio is statistically unreliable → gust component = 100 (the wind component already punishes calm conditions). **Precipitation** (mm/h): 0 → 100; 0 < p ≤ 0.5 → 60; 0.5 < p ≤ 2.0 → 20; > 2.0 → 0. **Direction vs. spot sectors** (per-spot data, `docs/06`): wind bearing falls in a `clear` sector → 100; `marginal` → 50; `hazard` → 0. **Temperature comfort** (°C): 100 for 10–28; linear 0→100 from −10 to 10; linear 100→0 from 28 to 40; 0 outside −10…40. **Hard rules (applied after the weighted sum, in this order):** 1. **H1 Thunderstorm:** WMO weather code 95–99, or an active NWS thunderstorm/lightning alert ⇒ final score **0**, label **No-fly**, reason `"thunderstorm"`. Never overridden. 2. **H2 Wind out of range:** wind component = 0 (sustained outside the flyable band) ⇒ final score **0**, reason `"wind_out_of_range"`. (Other components must not drag an unflyable hour up to "Marginal".) 3. **H3 Offshore for kitesurfing:** kite type is a kitesurf class AND wind bearing in the spot's `offshore` sector ⇒ final score **capped at 25**, mandatory warning `"offshore_wind"`. (Offshore wind blows a rider out to sea — always surfaced, even if conditions are otherwise perfect.) **Rounding & labels:** weighted sum computed in floating point, rounded **half-up to an integer** before labeling. 80–100 Excellent · 60–79 Good · 40–59 Marginal · 20–39 Poor · 0–19 No-fly. --- ## 2. Worked examples ### Example A — Casual single-line, slightly too windy (`fixture: A_single_line_fresh`) Inputs: sustained **8.0 m/s**, gust **11.2** (G = 1.40), precip 0, direction in `clear` sector, temp 18 °C, weather code 2 (partly cloudy). | Component | Calculation | Score | × weight | |---|---|---|---| | Wind | 8.0 is between ideal-max 7.0 and flyable-max 9.0 → (9.0−8.0)/(9.0−7.0)×100 | 50.00 | 25.000 | | Gust | G=1.40 in (1.3,1.6] → 100 − (1.40−1.30)/0.30×50 | 83.33 | 16.667 | | Precip | 0 mm/h | 100 | 15.000 | | Direction | clear | 100 | 10.000 | | Temp | 18 °C in comfort band | 100 | 5.000 | Weighted sum = 25.000 + 16.667 + 15.000 + 10.000 + 5.000 = **71.667** → round → **72**. No hard rules trigger. **Final: 72 · Good.** UI explanation surfaces the dominant penalty: "Wind a bit strong for single-line kites." ### Example B — Sport dual-line, strong gusty with drizzle (`fixture: B_dual_line_gusty`) Inputs: sustained **10.0 m/s**, gust **17.0** (G = 1.70), precip 0.3 mm/h, direction in `marginal` sector, temp 12 °C, weather code 51 (light drizzle). | Component | Calculation | Score | × weight | |---|---|---|---| | Wind | 10.0 between ideal-max 9.0 and flyable-max 12.0 → (12−10)/(12−9)×100 | 66.67 | 33.333 | | Gust | G=1.70 in (1.6,2.0] → 50×(2.0−1.70)/0.40 | 37.50 | 7.500 | | Precip | 0.3 mm/h → light band | 60 | 9.000 | | Direction | marginal | 50 | 5.000 | | Temp | 12 °C | 100 | 5.000 | Weighted sum = **59.833** → round half-up → **60**. No hard rules. **Final: 60 · Good** (boundary case — fixture intentionally exercises the rounding rule). ### Example C — Kitesurf 12 m, perfect wind but offshore (`fixture: C_kitesurf_offshore`) Inputs: sustained **7.0 m/s**, gust **8.4** (G = 1.20), precip 0, wind bearing in the spot's `offshore` (= `hazard`) sector, temp 24 °C, weather code 1. | Component | Score | × weight | |---|---|---| | Wind (7.0 inside ideal 6–12) | 100 | 50.000 | | Gust (G=1.20 ≤ 1.3) | 100 | 20.000 | | Precip | 100 | 15.000 | | Direction (hazard) | 0 | 0.000 | | Temp | 100 | 5.000 | Weighted sum = **90.0** → hard rule **H3** caps at **25**. **Final: 25 · Poor**, `warnings: ["offshore_wind"]`. The breakdown still shows the raw 90 so the UI can say: "Great wind — but it's blowing offshore here. Not safe to launch." ### Example D — Thunderstorm override (`fixture: D_thunderstorm`) Inputs: any kite type; sustained 6.0 m/s, gust 7.0, precip 1.2 mm/h, clear sector, temp 22 °C, weather code **95**. Hard rule **H1** short-circuits: **Final: 0 · No-fly**, `reasons: ["thunderstorm"]`. The breakdown object is still populated (wind 100, gust 100, precip 20, dir 100, temp 100) for transparency, but the API marks `hard_rule: "thunderstorm"`. ### Example E — Dead calm (`fixture: E_calm`) Inputs: `single_line`; sustained **1.0 m/s** (below flyable-min 1.5), gust 2.5, precip 0, clear sector, temp 25 °C, code 0. Wind component = 0 (below flyable range). Gust component = 100 (sustained < 2.0 m/s rule). Without H2 the weighted sum would be 0 + 20 + 15 + 10 + 5 = 50 ("Marginal") — nonsense for unflyable calm. Hard rule **H2** applies: **Final: 0 · No-fly**, `reasons: ["wind_out_of_range"]`. ### Example F — Cold-weather power foil, heavy rain (`fixture: F_foil_cold_rain`) Inputs: `power_foil`; sustained **8.0 m/s**, gust 9.6 (G = 1.20), precip **3.0 mm/h**, clear sector, temp **2 °C**, weather code 63 (moderate rain). | Component | Calculation | Score | × weight | |---|---|---|---| | Wind | 8.0 inside ideal 5–11 | 100 | 50.000 | | Gust | 1.20 ≤ 1.3 | 100 | 20.000 | | Precip | 3.0 > 2.0 mm/h | 0 | 0.000 | | Direction | clear | 100 | 10.000 | | Temp | (2−(−10))/(10−(−10))×100 = 12/20×100 | 60.00 | 3.000 | Weighted sum = **83.0** → **83**? No — note the precip contribution is 0, so the sum is 50 + 20 + 0 + 10 + 3 = **83.000** → round → **83**. No hard rule (heavy rain is miserable but not a hard safety stop absent thunderstorm codes). **Final: 83 · Excellent — with the precip component at 0 surfaced prominently** ("strong steady wind; heavy rain"). This fixture exists precisely because reviewers questioned whether heavy rain should hard-cap; rubric v1.0 deliberately leaves the decision to the user, and the per-component breakdown makes the rain unmissable. Revisit in v1.1 with real user feedback. --- ## 3. Boundary-value vectors (machine-checkable, no prose) All in `examples/scoring-fixtures.json`; summarized here: | Fixture | Asserts | |---|---| | `G1_gust_boundary_130` | G = 1.30 exactly → gust component 100 (boundary inclusive) | | `G2_gust_boundary_160` | G = 1.60 exactly → gust component 50 | | `G3_gust_boundary_200` | G = 2.00 exactly → gust component 0 | | `W1_flyable_min_edge` | sustained = flyable-min exactly → wind component 0 → H2 → final 0 | | `W2_ideal_min_edge` | sustained = ideal-min exactly → wind component 100 | | `P1_precip_zero_edge` | precip = 0.0 → 100; precip = 0.5 → 60; precip = 2.0 → 20 | | `R1_round_half_up` | weighted sum 59.5 → final 60 (Good), not 59 | ## 4. What implementations must expose Per `api/openapi.yaml`, every score response includes: `score` (int 0–100), `label`, `scoring_version` (`"1.0"`), `breakdown` (`wind`, `gust`, `precipitation`, `direction`, `temperature` — each `{score, weight}`), `hard_rule` (nullable string), `warnings` (string array), and the raw inputs used. The fixtures assert on `score`, `label`, `hard_rule`, and `warnings`; `breakdown` values are asserted to ±0.01.