The problem.
Global surf forecasts run on the same physics. A wave-height number from a buoy-scale ocean model gets multiplied by a generic shoaling coefficient and presented as a forecast. The number is right, on average, across thousands of breaks. But waves don't break on averages — they break on specific reefs, in specific coves, in specific wind funnels. Two days with identical 1.0m / 12s / 198° offshore signatures can produce wildly different sessions at Saladita: one peeling cleanly, one fast and short, one a closeout. The offshore data can't see the difference. Local knowledge can.
Our approach: instead of writing the local knowledge as rules (which we also do), we let the model learn it from a multi-year corpus of qualitative surf-condition descriptors written by surfers familiar with this break.
The training data.
The labels come from years of regional surf-condition descriptions — the kind of qualitative summaries that experienced surfers use to describe what a session was actually like. Categories like "fast and short," "lots of water," "soft glassy morning," "slow," "small but fun" are not casual filler. They're distinct surf-condition descriptors, used consistently. Each one labels a day.
The training set is a multi-year corpus of these category labels mapped to daily ocean and atmospheric features at Saladita's coordinates. The seven labelled categories are:
| Category | n | What it captures |
|---|---|---|
| love | 44 | Best days. Long peeling rides, offshore wind, ideal swell direction. |
| good | 19 | Solid sessions. Wave shape held; conditions clean. |
| fun | 13 | Workable, enjoyable conditions — not signature but worth surfing. |
| soft_good | 9 | Glassy, soft, mellow mornings. |
| small_fun | 5 | Small swell but clean enough to be playful. |
| fast_short | 10 | Quick closeouts. The wave doesn't hold its shape. |
| slow | 3 | Weak, sluggish wave with little push. |
The five top categories are merged into a single positive class (90 days) and the two bottom categories into a single negative class (13 days). The classifier learns to distinguish between them.
The features.
For each labeled day, we pull the marine and wind archive at Saladita's coordinates (17.8375°N, 101.7606°W) from Open-Meteo's ERA5-backed reanalysis. We aggregate hourly readings into two daily blocks:
AM block (6 AM – 10 AM local), capturing the morning session when dawn-patrol-tier conditions hold. PM block (2 PM – 6 PM local), capturing the afternoon when the thermal sea breeze typically deteriorates the wave.
For each block, we record: wave height (peak), wave period (mean), wave direction (mean), swell-only direction and period, wind direction, wind speed, and wind-wave height (a proxy for local chop). Plus a derived "offshore-ness" score — cosine of the wind direction relative to 45° NE, the direction of the morning land breeze at Saladita.
Eighteen features in total. The model is given no information about what each feature means — only the numbers and the label. It finds the patterns.
The model.
A logistic regression with L2 regularization, balanced class weights (because there are 7× more positive than negative labels), and 5-fold stratified cross-validation. We chose logistic regression deliberately: it's simple, the coefficients are interpretable, and with only 13 negative examples a more flexible model would overfit immediately. The model learns a weighted sum of standardized features and passes it through a sigmoid to produce a probability between 0 and 1.
The cross-validated AUC is 0.60 ± 0.12. This is honest — better than random (which would be 0.50), but not strong. The reason is partly statistical: the offshore wave model can't fully resolve the local windswell chop that drives "fast and short" days. Two days look identical on the buoy-scale and feel different at the break. And partly methodological: we have 90 positive labels and 13 negative — that's not enough data to learn a sharp boundary, and the base rate of "good" days in the labelled corpus is 87% — likely a publication-bias artifact, since people tend to describe sessions they enjoyed.
What the model learned (top feature weights)
| Feature | Weight | What it means |
|---|---|---|
| PM wind-wave height (chop) | −0.74 | Strongest negative signal. Afternoon chop predicts "fast/short" descriptors — confirming that local windswell contaminating the groundswell is what breaks the wave at Saladita. |
| AM swell direction | +0.58 | Where the swell is coming from in the morning. The model finds a clear sweet spot in the 195–215° band; deviations either side hurt. |
| PM wind speed | −0.48 | Above ~5 knots in the afternoon, the wave loses shape. Below that, even an onshore-leaning direction can still produce surfable conditions. |
| AM wave period | +0.48 | Longer-period swells in the morning are more organized. Below ~11s the model gets less confident. |
| AM swell period | −0.45 | An interesting negative — possibly capturing that too long-period swells (15+s) overpower Saladita's shallow bathymetry into closeouts on the inside. The model is small enough that this could also be sample noise. |
| PM wind direction | +0.44 | Direction matters more than speed in the afternoon. Westerly cross-shore wind can still be glassy; pure-onshore SW kills it. |
| PM wave height | +0.43 | The model marginally prefers bigger afternoons — but the coefficient is smaller than the wind features, confirming what locals already say: cleanliness matters more than size. |
Eleven other features carry smaller weights. The full coefficient vector is in the source code at functions/api/_model_artifacts.js.
The energy and percentile layer.
Separately from the classifier, we compute the wave energy flux per unit crest length using the deep-water formula:
P = (ρg² / 64π) · H² · T ≈ 0.49 · H² · T kW/m
Where H is significant wave height in meters and T is wave period in seconds. This number — kilowatts per meter of wave crest — is a better summary of "how much wave is hitting the beach" than wave height alone. A 1.5m wave at 16s carries 76% more energy than a 1.5m wave at 9s, and surfers feel that difference even though the height number is the same.
We also rank each day's peak wave power against the same calendar month at the nearest ERA5-Wave grid node to the corrected site coordinates (17.5°N, 102.0°W, about 45 km offshore to the south-west) — offshore forcing, not conditions at the break — 4,281 days of ERA5-Wave marine archive from January 2015 to September 2026, split by month (330–372 days each). The percentile tells you not just how big today is, but how big it is relative to what this break sees at this time of year. The bar moves a lot by season: a 30 kW/m day sits above the 98th percentile from November through April, at the 95th–98th in May, July, August and October, and only at the 89th in June and the 93rd in September — big everywhere, but routine-big in the two peak south-swell months. Before 2026-09-26 the ranking pooled every month at a node 44 km east of the break, which read the median day as 8.6 kW/m; the corrected offshore node reads it as 10.7.
The extended outlook.
Past the 10-day direct forecast window, we forecast the storm source regions rather than the destination. Southern Hemisphere winter storms at 35–50°S take 6–8 days to send groundswell to Mexico. By pulling 16-day wave forecasts at the 50°S 130°W and 35°S 130°W grid points and applying fixed travel lags (7, 6 and 3 days for the three source regions, a simplification of deep-water group velocity ≈ 0.78 × period that the code does not yet compute per storm), we can flag major storms 5–7 days before they'd appear in the direct Saladita forecast. The result is a "what's brewing" section showing arrivals projected 10–14 days out.
This is uncertain. Storms forecast 10+ days out have wide ensemble spread, directional aim can shift, and not every 6m+ wave-height in the storm belt makes it to Mexico cleanly. We surface only major (≥9m Hs) and solid (≥6m Hs) storms, and tag them so readers know to treat them as possibilities rather than confirmations.
The current estimate.
Each hour carries a rough estimate of the sideways drift along the beach (longshore current) and a rip outlook. Neither is measured at La Saladita. They are textbook surf-zone physics applied to the forecast swell.
Breaker height. From the forecast wave height and the primary swell period, using Komar and Gaughan (1972): Hb = 0.39 g0.2 (T H02)0.4. A 1.7 m, 13-second swell gives a breaker of about 2.6 m.
Sideways drift. The swell is bent toward the shore with Snell's law as it slows from deep water to the breaking depth (Hb / 0.78), and the drift speed comes from the Longuet-Higgins and Komar formula, V = 1.17 √(g Hb) sinαb cosαb, where αb is the angle between the breaking crest and the shore. That angle is the number we don't know. We compute it twice: once for the regional coastline (seaward normal 221°) and once for the ride over the point (275°, from satellite shoreline transects, pending a compass bearing on the beach). The forecast shows the range between the two. Snell's law assumes straight depth contours, and the point bends them, so the high end can overstate the drift. On a south swell both shorelines push the water to the north-west and north, down the line of the wave.
Rip outlook. Rip speed depends on where the sandbars and channels sit, which no forecast sees, so there is no number. The outlook is a tier from breaker height (lower under 1 m, moderate 1–2 m, higher above 2 m), raised one step at low tide, following the field pattern that rips run faster with bigger waves and lower tide (MacMahan, Thornton and Reniers, 2006, Earth-Science Reviews 79). Field studies typically measure rips at 0.3–0.8 m/s with faster pulses on sets.
What to do with it. Treat it as a prompt to look, not a reading. Sit still for a minute in the lineup and watch how far you move against a landmark on the beach; that is the measurement.
The water level estimate.
Storms do their damage on this beach through high water: big waves arriving while the sea is already high push the swash up the sand and pull the beach away. Each forecast day carries an estimate of the highest water level at the beach and ranks it against every day since late 2021.
The recipe. Water level = tide + how far the sea is running above the tide + wave run-up. The tide comes from the site's harmonic model of the Zihuatanejo gauge (metres above mean lower-low water). How far the sea is running high is measured: the live Zihuatanejo tide gauge (IOC station zihu, 31 km south-east) minus the predicted tide, averaged over the last 72 hours and held steady through the forecast. It carries El Niño, low pressure and storm water together. Run-up is the Stockdon et al. (2006) 2% run-up, computed from the Météo-France MFWAM wave height and mean period at the forecast point and a beach slope of 0.046 estimated from Sentinel-2 shorelines on this beach.
The record. The same recipe over 1,758 days with gauge data (2021-11-20 to 2026-10-14) gives a median daily high of 1.42 m, a 95th percentile of 1.92 m and a 99th of 2.13 m. Storm peaks: Hurricane John reached 2.28 m on 2024-09-26 (tide 0.61 m, sea running 0.25 m high, run-up 1.42 m) with one day above the 95th percentile; Hurricane Polo reached 2.23 m on 2026-09-24 (0.39, 0.38, 1.46) with five such days; the week of Hurricane Simon reached 2.17 m on 2026-10-08 (0.47, 0.44, 1.26) with five of seven days above the 95th percentile. Since August 2026 the sea has run 0.2–0.5 m above the tide, measured at both Zihuatanejo and Lázaro Cárdenas, so ordinary swells now reach water levels that only storms reached before. Days at or above the 95th percentile are flagged as high water.
What it leaves out. Wind pushing water onto the beach is left out because it is small here: across the site's ETOPO1 shelf profile (200 m deep only 9.7 km out) steady onshore wind piles up about 1.4 cm at 34 kt and 3.7 cm at 50 kt, and about three times that if the unresolved last 2 km is as shallow as 5 m. Regional storm water already shows up in the gauge term. Wave setup beyond the Stockdon term is not modelled, and the National Hurricane Center does not publish surge forecasts for Mexico. Holding the measured offset steady assumes the sea neither rises nor falls over the forecast. Run-up uses the mean wave period because MFWAM does not supply a peak period here. The total has not been checked against high-water marks at La Saladita; photos of where the water reached after a storm would be the first check.
What this can't do.
The list is long enough to be worth being clear about.
Calibrated probabilities. A 95% P(good) from this model doesn't mean 95% certainty that today will be amazing. It means today looks structurally like 95% of the labeled good days in the training corpus. Calibration would require a held-out test set with confirmed outcomes — and would still be limited by the base rate skew.
Distinguishing levels of good. The classifier doesn't separate "love" from "good" from "fun". All collapse into the positive class. A high probability could mean a once-a-month session or just a normal fun morning.
Surprise events. Overnight sandbar drift, a sudden 6 AM squall, an unforecasted swell pulse arriving on a 17-second period — all invisible to the inputs.
Local intel the data can't see. Whether the tide is dropping or rising at the moment the swell arrives; whether the cobblestones on the inside have shifted from a big winter storm; whether a surf-camp van just unloaded twelve students at the takeoff. The model has marine and wind features only.
Generalization to other breaks. This model is trained on Saladita captions specifically. The exact feature weights — what makes "good" — would be different at Troncones, La Boca, Puerto Escondido. Same architecture, different training data.
Nearby spots and why they don't get a quality score.
The forecast response includes raw open-coast values for The Ranch, Troncones, Manzanillo Bay, La Boca, Playa La Ropa, Playa Linda, and Barra de Potosí — each pulled from its own Open-Meteo grid cell (~10–25 km resolution). What you'll not see for these spots is a 1-to-10 quality score or a refraction-adjusted face-height estimate.
The reason is simple. Saladita's rules engine works because it has been calibrated against a couple of weeks of ground-truth observations from the water — gust thresholds, mixed-period failures, fat-shoulder days that look fine on paper. The other spots have zero observations. Inventing a "0.65 refraction multiplier for Manzanillo Bay" would dress a guess in decimal places. We'd rather report the open-coast number Open-Meteo actually gives us, plus a geographic note about how that number tends to translate at each break, and let a human surfer make the final call.
Per-spot rules engines will graduate once n ≥ 10 observations exist for that spot. Until then, raw + geographic note is the honest answer. Observation log format and contribution instructions live at /data/ground_truth/ in the site repo.
The honesty axis.
We publish the AUC. We publish the training set size. We publish the feature weights. We publish what the model can't do. This is not because we don't believe in the model — we do, otherwise we wouldn't ship it. It's because surf forecasting is full of confident-sounding tools whose actual skill is closer to "marginally better than coin-flip" than to "the wave will be 1.4m/12s/198° at 7:00 AM." A reader who sees a 95% P(good) badge should know what that number is and isn't claiming. That knowledge changes how they use the page.
Data sources and code.
Marine data: Open-Meteo Marine Weather API — wave height, period, direction, swell components, wind-wave height. Free, public, no key required. For this location the API's default wave model is Météo-France MFWAM, and the site's swell history uses the same model's archive, which starts in October 2021. The long weather record uses ERA5 reanalysis.
Atmospheric data: Open-Meteo Weather API — temperature, wind speed and direction, precipitation, at the Saladita grid point. Updated hourly.
Training labels: Anonymized aggregation of public surf-condition descriptors written by surfers at this break. Caption text and post timestamps only — no images or video, and no individual contributor named.
Code: The forecast function lives at /functions/api/forecast.js on this site (Cloudflare Pages Function, ES module). The model artifacts — feature names, scaler parameters, coefficients, historical distribution — are at /functions/api/_model_artifacts.js. Both are visible to anyone who fetches them.
Versioning.
The classifier feeds nothing into the quality score; the rules engine alone produces it. Trained on 82 labeled days from 2019-07-16 to 2026-05-19 at the pre-correction grid point (44 km from the beach), cross-validated AUC 0.598 ± 0.120, an interval that includes chance. Since 2026-09-27 its badge is hidden and the number is exposed only as archetype_fit.prob_pct_experimental in the API. Retrain at the corrected offshore node, 2026-09-27. The same caption-labelled days were rebuilt at 17.8375, −101.7606 (all 103 of them this time: 90 good, 13 fast/short/slow, base rate 87%) from the ERA5-Wave archive, which resolves that point to its 0.5° grid cell at 17.5, −102.0, and the ERA5 wind archive. Three defects in the old features were fixed: directions are now averaged as vectors, wave direction enters as distance from the 205° pocket and wind as the cosine from the NE land breeze instead of a straight line in degrees, and inputs are clipped to the training range. The swell-partition and wind-wave features were dropped because ERA5-Wave does not carry them at this cell, leaving ten. The rule was written in the training script before the run: the badge returns only if the out-of-sample AUC's 95% confidence interval starts above 0.55. Grouped by year (each year held out in turn, no random splits), the AUC was 0.48 (95% CI 0.29–0.67); held out by month it was 0.58 (0.43–0.75), and trained only on earlier years it was 0.46 (0.27–0.66). Out-of-sample probabilities from 0.16 to 0.99 all came with an observed good rate of 85–91%, so the model does not calibrate either. The badge stays hidden. Thirteen negative days, six of them from 2023, are too few to learn what separates a fast, short day from a good one. The full evaluation is in MODEL.retrain_2026_09_27. Tide phase is already in the rules engine (from the harmonic model of the Zihuatanejo gauge).
Station audit, 2026-09-27. Three rules changed after scoring the ledger against the on-site station. Wind clock: on the station's trusted months (2023-11 to 2024-12 and 2026-07 to 2026-09) the land breeze blows offshore from the ENE all night and the sea breeze turns onshore (SW) at a median 10:25 local (interquartile range 10:00–10:55; by month, 11:00 in January, 09:40 in March, 10:40 in August), which gives roughly 3.3 clean hours after first light; each day's forecast now carries that month's turn time and the narrative says so. Night lows: over 68 ledger days the model's minimum-temperature correlation with the station is 0.15–0.27 at either grid point, the station's night-to-night spread is 1.7 °F, and a 30-day running station mean (error 1.45 °F) beats the raw model (1.88), the bias-corrected model (1.71) and persistence (1.64); at the correct grid point the model runs about 1.5 °F warm on lows and unbiased on highs, so the weather API serves the low as the model minus 1.3 °F and says plainly that the low is not forecastable beyond the recent mean. Rain threshold: over 76 settled days the model called at least 1 mm on 58% of days while it rained on 29%; a model total of 0.05–3 mm was followed by rain about one day in seven, 3–15 mm about three days in five, and more than 15 mm three days in four, so the daily rain call now uses a 3 mm threshold (probability of detection 0.82, false-alarm ratio 0.40, against 0.91 and 0.55 at 1 mm). Wet-season rain falls mostly at night: the 06–12h block carries about 8% of it.
Suggestions, corrections, or labeled-day contributions: please send a note. The model gets better the more honest labels it sees.