A surfer does not book time off based on the weekend forecast. They book it three months ahead, when no swell forecast exists. Nalu answers differently: it measures, across four years of archive, the probability that a spot works in October, then combines that probability with the airfare out of Paris.
20spots, each with its source and its confidence level
240spot × month pairs reranked on every move of the slider
701 280swell hours in the versioned cache160 parquet files, 11.8 MB
0 €in total cost, a constraint set before the first line
01 / The problem
Three months ahead, ten days of forecast
The decision horizon for a trip is three months. The horizon of a swell forecast is ten days. Between the two there is nothing, and surf guides fill the gap with unverifiable claims, never set against the airfare, which decides the trip as much as the wave does.
At that horizon, the only thing you can honestly write is a probability: "across four years of archive, this spot worked for this many hours in October" rather than "there will be swell". The first sentence can be checked.
02 / The approach
One probability, one price, a single slider
A single quality measure: the probability that an hour of daylight is surfable, evaluated hour by hour across four years. No composite score, no expert weighting, because you do not add a probability to an unbounded quantity.
Price is the second axis, and the two are compared as percentile ranks over the 240 pairs, never through min-max normalization. A single outlier would be enough to crush the scale and leave the slider inert. A tested property guarantees it: adding an absurd price does not change the order of the others.
Q is a pure probability. Swell intensity is computed and displayed, but it stays out of the score, because you do not add a probability to an unbounded quantity. Ranks are percentiles computed over the 240 spot × month pairs. The slider moves α between 0 and 1: at 1, only the wave counts; at 0, only the airfare.
03 / What was built
Four building blocks, each held in place by a test
The repository is written to be read, not to run in production: every assumption in the model is isolated, typed, and justified in one sentence. And the rules that matter do not live in a document, they live in the tests. Breaking one makes the suite fail.
Diagram of the principle. From each spot, 180 rays are cast in 2° steps against the coastline polygons; the widest arc reaching 500 km of open ocean becomes the exposure window. Fifteen spots out of twenty recover the direction published in the guides this way, with no intervention. The other five carry a manual correction and a written reason. Without a reason, the model refuses to load.
Ingestion
Official Open-Meteo client, no homemade HTTP client or backoff. The quota is weighted rather than counted in requests: 2,296 units consumed in 8 calls and 196 seconds to fill the entire archive.
Spot geometry
Swell exposure windows are not declared, they are computed: 180 rays per spot cast against the coastline polygons, with a threshold of 500 km of open ocean. Fifteen out of twenty recover the published direction with no intervention.
Ranking computation
Vectorized expressions, no row-by-row loops. 1.75 million rows processed in under 60 seconds. A performance test would fail on a naive implementation, which makes the rule enforceable rather than declarative.
Application
Dashboard sitting on a versioned parquet cache: zero network calls at runtime, verified by a test that cuts the socket. The demo runs without internet, which protects a demonstration given at a client site.
04 / What measurement corrected
Five decisions taken against the original plan
The starting plan was amended five times by measurement. Each departure stayed written in the repository rather than rewritten afterward: what you decide once the result is known is no longer a decision, it is a justification.
01
Ten years of archive dropped for four
The ERA5-Ocean reanalysis serves no pure swell before December 2021: the field is empty, year after year, contrary to what its documentation states. That left a choice between ten years of total sea, swell and wind chop mixed together, and four years of swell alone. Total sea overestimates swell by 22 cm at the median, and by more than 50 cm on 14% of hours. I kept the four years.
02
External validation fails, and the threshold did not move
The model recovers the published high season on 10 spots out of 20. The required threshold, 70%, had been written before the measurement, and it was not moved afterward. The detail is more instructive than the rate: the median error is zero, so when the model is right, it is exactly right. Six failures out of ten come down to a single month. And a test forbids reaching the metric by removing the spots that degrade it.
03
The pricing bias, measured before being suffered
Prices come from the search cache of an affiliate platform, so coverage tracks tourist traffic. Measured rank correlation: +0.78. The five destinations with no price at all are exactly the five with the lowest popularity. Moving to percentile ranks would have made this bias invisible without removing it, and the ranking would have ended up copying tourist traffic without anyone seeing it. Those five spots stay on display, and they are flagged.
04
A twelfth parameter born of a vocabulary confusion
Surf guides publish a peak period; the source only serves a mean period. Confusing the two made Uluwatu surfable 14% of the time and the Maldives 0%. An explicit conversion factor, applied before any comparison, took the model from eleven parameters to twelve. They all live in a single file, each followed by the sentence that justifies it, and a test refuses the addition of a parameter without a justification.
05
A wind condition that declared a 45 knot wind surfable
The original rule combined wind sector and wind speed with an OR. An offshore wind at 45 knots therefore passed the test. It was corrected into a nested condition (if the wind comes from land, then one ceiling, otherwise another, lower one) during the review, before the first line of code. This is the kind of error no test catches afterward, because the result stays plausible.
05 / Results
Why a spot at 0.7% wins the month of January
Slider in the middle, in January, Ponta Preta comes first with 0.7% surfable hours: ten hours out of the 1,364 daylight hours that January adds up to across four years. It wins because the fare is €301.
La Gravière has the best swell of the month in the whole ranking and finishes third, because no price was available. A spot with no price gets rank 0, which penalizes it rather than neutralizing it. That is a deliberate choice, and the table shows it instead of burying it in the code.
Rank
Spot
Surfable
Quality rank
Return fare
Price rank
Score
1
Ponta Preta
0.7%
0.326
€301
0.768
0.547
2
Uluwatu
2.2%
0.494
€683
0.580
0.537
3
La Gravière
28.3%
0.992
n/a
0.000
0.496
January, slider at midpoint (α = 0.5). Percentile ranks computed over the 240 spot × month pairs.
06 / The limits
What the model does not do
No tide. It is decisive on most reefs, but no free source covers it worldwide across several years of archive.
Neither bathymetry nor refraction, both out of reach at a 50 kilometer grid.
Offshore heights, not wave faces. The thresholds are named accordingly, so the shorthand cannot slip in by accident.
Four years under-sample year-to-year variability. An El Niño episode weighs more heavily over four years than it would over ten.
Twenty spots, not fifty. The thresholds are the product's central asset, and twenty sourced spots are worth more than fifty guessed ones.
What Nalu says about an engagement at your company
This project has no client. What it shows is a method: measure before asserting, write the threshold before knowing the result, publish the gap when one remains. I apply the same thing to company data.