{"id":13879487,"url":"https://github.com/swrobel/meta-surf-forecast","last_synced_at":"2025-11-11T19:34:46.804Z","repository":{"id":3600140,"uuid":"50268181","full_name":"swrobel/meta-surf-forecast","owner":"swrobel","description":"🌊 Modern swell buoy charts + Aggregated surf forecast from Surfline \u0026 Spitcast APIs","archived":false,"fork":false,"pushed_at":"2025-05-09T03:55:14.000Z","size":4305,"stargazers_count":316,"open_issues_count":4,"forks_count":61,"subscribers_count":49,"default_branch":"main","last_synced_at":"2025-05-13T12:08:45.397Z","etag":null,"topics":["forecast-data","ndbc","ndbc-buoy-data","spitcast","surf","surf-forecast","surfing","surfline"],"latest_commit_sha":null,"homepage":"https://metasurfforecast.com","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/swrobel.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2016-01-24T02:04:58.000Z","updated_at":"2025-05-09T03:55:18.000Z","dependencies_parsed_at":"2023-12-26T22:20:30.797Z","dependency_job_id":"9982226c-fc92-4cbe-93f7-099f8836390b","html_url":"https://github.com/swrobel/meta-surf-forecast","commit_stats":{"total_commits":586,"total_committers":5,"mean_commits":117.2,"dds":"0.0068259385665528916","last_synced_commit":"e2070d2bbb3210a9bb4a592d9274cd12e0dcd1b2"},"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/swrobel%2Fmeta-surf-forecast","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/swrobel%2Fmeta-surf-forecast/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/swrobel%2Fmeta-surf-forecast/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/swrobel%2Fmeta-surf-forecast/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/swrobel","download_url":"https://codeload.github.com/swrobel/meta-surf-forecast/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254485060,"owners_count":22078767,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["forecast-data","ndbc","ndbc-buoy-data","spitcast","surf","surf-forecast","surfing","surfline"],"created_at":"2024-08-06T08:02:22.593Z","updated_at":"2025-11-11T19:34:46.798Z","avatar_url":"https://github.com/swrobel.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# Meta Surf Forecast\n\n- [Meta Surf Forecast](#meta-surf-forecast)\n  - [Purposes](#purposes)\n  - [Developer Setup](#developer-setup)\n    - [Prerequisites](#prerequisites)\n    - [Steps](#steps)\n    - [Tips](#tips)\n  - [Adding Spots](#adding-spots)\n  - [Data Sources](#data-sources)\n    - [Surfline](#surfline)\n      - [New API (v2)](#new-api-v2)\n        - [Responses](#responses)\n        - [Requests](#requests)\n      - [Old API (v1 - no longer supported)](#old-api-v1---no-longer-supported)\n    - [Spitcast](#spitcast)\n    - [MagicSeaweed (no longer supported)](#magicseaweed-no-longer-supported)\n  - [The Magic](#the-magic)\n    - [Surf quality ratings](#surf-quality-ratings)\n      - [Surfline v2](#surfline-v2)\n      - [Spitcast](#spitcast-1)\n    - [Timestamps](#timestamps)\n  - [TODO](#todo)\n\n## Purposes\n1. Display a chart of approximate wave height (swell height * period * 0.1) for the last 24 hours from [NDBC Buoys](https://www.ndbc.noaa.gov/)\n  ![Screenshot](https://raw.githubusercontent.com/swrobel/meta-surf-forecast/main/screenshot-buoy.png)\n1. Query the [Surfline](https://www.surfline.com/) \u0026 [Spitcast](https://www.spitcast.com/) APIs to display an aggregated surf forecast.\n  ![Screenshot](https://raw.githubusercontent.com/swrobel/meta-surf-forecast/main/screenshot-forecast.png)\n\n## Developer Setup\n\n### Prerequisites\n* `ruby 3.4.x`\n* `node 22.x`\n* `yarn 1.x`\n* `postgresql`\n\n### Steps\n1. Install dependencies using [Homebrew](https://brew.sh/): `brew bundle`\n1. If on Linux: `pg_ctl -D /home/linuxbrew/.linuxbrew/var/postgres start`\n1. `gem install bundler -v=$(cat Gemfile.lock | tail -1 | tr -d \" \")`\n1. `bundle`\n1. `yarn`\n1. `bin/rails db:create db:schema:load:with_data db:seed`\n1. Grab some data: `bin/rails buoys:update forecasts:update`\n1. `bin/foreman start -f Procfile.dev`\n1. Open http://localhost:5001\n1. Any changes you make to view files will auto-reload the browser\n\n### Tips\n* You will not get Surfline forecast data without a valid Surfline premium login. Add your credentials to `.env.development`:\n  ```\n  SURFLINE_EMAIL=xxx\n  SURFLINE_PASSWORD=yyy\n  ```\n  Then run `bin/rails forecasts:update` again\n* When running migrations, use `bin/rails db:migrate:with_data` to include Data Migrations\n\n## Adding Spots\n\nContributing new spots is easy! Make sure you're signed into your [Github account](https://github.com/join) and edit the [seeds file](https://github.com/swrobel/meta-surf-forecast/edit/main/db/seeds.rb):\n\n1. Create a new Region/Subregion if necessary. For example, Los Angeles is created like so:\n    ```ruby\n    CA = Region.find_or_create_by(name: 'California')\n    LA = Subregion.find_or_create_by(name: 'Los Angeles', region: CA)\n    LA.timezone = 'America/Los_Angeles'\n    LA.save!\n    ```\n    You can get valid timezone names from [this list](https://gist.github.com/swrobel/77626ff3d4967ca65c3028dcb336d57a).\n1. Use [this tool](https://boundingbox.klokantech.com/) to draw a bounding box around the area you want to find spots for.\n1. Choose CSV from the dropdown at the bottom \u0026 copy the coordinates string.\n1. Run `rails console`, then run `SpotFinder.new('{string}').formatted_spots`.\n1. Copy the output and paste it into the spots array in `seeds.rb`, then make sure to assign each spot to the right subregion \u0026 delete extraneous fields (`match_type`, `distance`).\n1. It's strongly encouraged to add all spots for a particular county or region rather than just a single one. Be a pal!\n1. Submit a pull request and I'll get it on the site ASAP!\n\nUse the following as a template. Add `spitcast_v2_id\n\n```ruby\n  {\n    name: 'County Line',\n    lat: 34.051,\n    lon: -118.964,\n    surfline_v2_id: '590927576a2e4300134fbed8',\n    subregion: LA,\n  },\n```\n\n## Data Sources\n\n### [Surfline](https://www.surfline.com/)\n\n#### New API (v2)\n\n##### Responses\n\nSurfline's new API is undocumented but easy to reverse engineer using their new website's code. Thankfully its structure is much more sane than the old API.\n\n##### Requests\n\n`https://services.surfline.com/kbyg/spots/forecasts/{type}?{params}`\n\nFor reference, I believe `kbyg` stands for \"Know Before You Go,\" which is their tagline.\n\nType|Data\n----|----\nrating|array of human-readable and numeric (0-6) ratings\nwave|array of min/max sizes \u0026 optimal scores\nwind|array of wind directions/speeds \u0026 optimal scores\ntides|array of types \u0026 heights\nweather|array of sunrise/set times, array of temperatures/weather conditions\n\nParam|Values|Effect\n-----|------|------\nspotId|string|Surfline spot id that you want data for. A typical Surfline URL is `https://www.surfline.com/surf-report/venice-breakwater/590927576a2e4300134fbed8` where `590927576a2e4300134fbed8` is the `spotId`\ndays|integer|Number of forecast days to get (Max 6 w/o access token, Max 17 w/ premium token)\nintervalHours|integer|Minimum of 1 (hour)\nmaxHeights|boolean|`true` seems to remove min \u0026 optimal values from the wave data output\naccesstoken|string|Auth token to get premium data access (optional)\n\nAnywhere there is an `optimalScore` the value can be interpreted as follows:\n\nValue|Meaning\n-----|-------\n0|Suboptimal\n1|Good\n2|Optimal\n\nHowever, I have never seen a score of 1 in any of their API responses (only 0 or 2), which is unfortunate when it comes to granularity of ratings. Hopefully this changes in the future.\n\n#### Old API (v1 - no longer supported)\n\nSurfline's old API is undocumented and unauthenticated, but was used via javascript on their website, so it was fairly easy to reverse-engineer. However, they have updated their site \u0026 apps to use the new API, and it appears that they've stopped including some critical data in the responses for the old API, so it's disabled in this app for now (and probably forever).\n\nIt returned JSON, but with a very odd structure, with each item that is time-sensitive containing an array of daily arrays of values that correspond to timestamps provided in a separate set of arrays. For example (lots of data left out for brevity):\n\n```json\n\"Surf\": {\n  \"dateStamp\": [\n      [\n        \"January 24, 2016 04:00:00\",\n        \"January 24, 2016 10:00:00\",\n        \"January 24, 2016 16:00:00\",\n        \"January 24, 2016 22:00:00\"\n      ],\n      [\n        \"January 25, 2016 04:00:00\",\n        \"January 25, 2016 10:00:00\",\n        \"January 25, 2016 16:00:00\",\n        \"January 25, 2016 22:00:00\"\n      ]\n    ],\n  \"surf_min\": [\n      [\n        2.15,\n        1.8,\n        1.4,\n        1\n      ],\n      [\n        0.7,\n        0.4,\n        0.3,\n        0.3\n      ]\n    ],\n}\n```\n\nRequests are structured as follows:\n\n`https://api.surfline.com/v1/forecasts/{spot_id}?{params}`\n\nThis is a breakdown of the params available:\n\nParam|Values|Effect\n-----|------|------\nspot_id|integer|Surfline spot id that you want data for. A typical legacy Surfline URL is `https://www.surfline.com/surf-report/venice-beach-southern-california_4211/` where 4211 is the `spot_id`. You can also get this from a v2 API response's `legacyId` property.\nresources|string|Any comma-separated list of \"surf,analysis,wind,weather,tide,sort\". There could be more available that I haven't discovered. \"Sort\" gives an array of swells, periods \u0026 heights that are used for the tables on [spot forecast pages](https://www.surfline.com/surf-forecasts/spot/venice-beach_4211/). To see the whole list, just set 'all'.\ndays|integer|Number of days of forecast to get. This seems to cap out at 16 for Wind and 25 for Surf.\ngetAllSpots|boolean|`false` returns an object containing the single spot you requested, `true` returns an array of data for all spots in the same region as your spot, in this case \"South Los Angeles\"\nunits|string|`e` returns American units (ft/mi), `m` uses metric\nusenearshore|boolean|The best that I can gather, you want this set to `true` to use the [more accurate nearshore models](https://www.surfline.com/surf-science/what-is-lola---forecaster-blog_61031/) that take into account how each spot's unique bathymetry affects the incoming swells.\ninterpolate|boolean|Provide \"forecasts\" every 3 hours instead of ever 6. These interpolations seem to be simple averages of the values of the 6-hour forecasts.\nshowOptimal|boolean|Includes arrays of 0's \u0026 1's indicating whether each wind \u0026 swell forecast is optimal for this spot or not. Unfortunately the optimal swell data is only provided if you include the \"sort\" resource - it is not included in the \"surf\" resource.\ncallback|string|jsonp callback function name\n\n### [Spitcast](https://www.spitcast.com/)\n\nSpitcast has a [documented API](https://github.com/jackmullis/spitcast-api-docs).\n\nI've also asked Jack from Spitcast a few questions and added his responses below:\n\n* Why does the site show a size range, but the API only returns one `size` value?\n  \u003e I actually take the API number and create the max by adding 1/6 the height (in feet), and then create the min by subtracting 1/6 the height.\"\n* All possible values for shape:\n  * Poor\n  * Poor-Fair\n  * Fair\n  * Fair-Good\n  * Good\n\n### [MagicSeaweed](https://magicseaweed.com/) (no longer supported)\n\nMagicSeaweed was acquired by Surfline an shut down in 2023. Prior to this, MagicSeaweed had a [well-documented JSON API](https://magicseaweed.com/developer/forecast-api) that required requesting an API key via email. This was a straightforward process and they got back to me quickly with my key.\n\nI've asked MagicSeaweed a few questions and added their responses below:\n\n* \"Our API provides 5 days of forecast data, with segments of data provided for each 3 hour interval during that 5 day time span.\"\n* \"Our data is updated every 3 hours.\"\n\n## The Magic\n\n### Surf quality ratings\n\nAll of the forecasting services (including Surfline v1 vs v2) use different systems for rating waves. I've attempted to normalize them all to a 0-6 (6-point) scale as best as possible, which is perhaps easier to understand when mapped onto the commonly-used Poor-Good scale. At the top-end, Very Good is the typical top end, while Epic is allowed for in the rare occasions where Surfline's rating goes above 5:\n\nValue|Meaning\n-----|-------\n0|Poor\n1|Poor - Fair\n2|Fair\n3|Fair - Good\n4|Good\n5|Very Good\n6+|Epic\n\nEach forecasting service is massaged onto that scale as follows:\n\n#### Surfline v2\n[5-point ratings](https://support.surfline.com/hc/en-us/articles/36277684017819-Surf-Ratings-Colors), which equate to decimal values (0-4). These are massaged by multiplying by 5/4 to get a 0-5 scale.\n\nThe scale can go above 4, but the top 2 values require a human forecaster override, so they are rarely seen.\n![Spinal Tap - It Goes to 11](https://i.imgflip.com/a4zyg1.jpg)\n\nValue|Meaning\n-----|-------\n0|Very Poor\n1|Poor\n2|Poor - Fair\n3|Fair\n4|Fair - Good\n5|Good\n6|Epic\n#### Spitcast\n\nValue|Meaning\n-----|-------\n0.0|Poor\n0.5|Poor-Fair\n1.0|Fair\n1.5|Good\n\nThese are massaged by multiplying by 5/1.5 (essentially 3.3̅) to get a 0-5 scale.\n\nFor record-keeping, these are the formulae for formerly-supported services:\n\n* **MagicSeaweed:** integer `fadedRating` (0-5) \u0026 `solidRating` (0-5). I simply subtract fadedRating (which is essentially the negative effect of wind) from solidRating.\n* **Surfline v1:** decimal ratings (0-1) for up to 6 different swells at each spot, as well as an `optimalWind` boolean. I take the max swell rating at any given time for that spot, multiply it by 5, and then halve it if the wind is not optimal.\n\n### Timestamps\n\nIt took me a long time to land on a solution here, but I've finally settled on storing all timestamps in the database in the spot's local time. This defies Rails convention, but makes intuitive sense. If you pull up the forecasts table and look at the timestamp, that's the actual local time at that spot that it's forecast for (even though Rails \u0026 Postgres both think it's being stored in UTC). This is typically the format that the forecasting service gives it to us in, and what users want to see it in, so there's no point in doing all sorts of fancy conversion when it should be the same all the way through the pipeline. Now, you may ask, why am I still using the Rails default of `TIMESTAMP WITHOUT TIMEZONE`, and the answer is that shockingly enough, `TIMESTAMP WITH TIMEZONE` [doesn't actually store timezone data](https://stackoverflow.com/a/9576170/337446)!\n\n## TODO\n\n* [ ] Use [SoFar API](https://docs.sofarocean.com/spotter-and-smart-mooring/spotter-data/latest-data) to get data from additional buoys\n* [ ] Figure out a way to convey forecast certainty in charts (ie: most forecasts are in agreement, or they disagree by a wide margin)\n* [ ] Fetch \u0026 display tide/wind/water temperature data from [NOAA](https://tidesandcurrents.noaa.gov/waterlevels.html?id=9410840) (they actually have a decent [API](https://tidesandcurrents.noaa.gov/api/)!)\n* [x] Update Surfline v2 API to use their new [7-point rating scale](https://support.surfline.com/hc/en-us/articles/14006471584411-Surfline-s-surf-rating) ([thanks @smmuirhead100](https://github.com/swrobel/meta-surf-forecast/pull/61)!)\n* [x] Improve charts:\n  * [x] Fix timestamp formatting.\n  * [x] Account for min/max size forecast. Currently charts just reflect the max.\n  * [x] Display forecast quality ratings. Perhaps color each bar different depending on how good the rating is. Surfline also has an `optimal_wind` boolean that is being crudely integrated into the [`display_swell_rating`](https://github.com/swrobel/meta-surf-forecast/blob/main/app/models/surfline.rb#L5) method - improvements welcome.\n* [x] Fetch \u0026 display [recent buoy trends](https://www.ndbc.noaa.gov/show_plot.php?station=46025\u0026meas=wvht\u0026uom=E\u0026time_diff=-7\u0026time_label=PDT) that are relevant to each spot to give an idea of when swell is actually arriving.\n* [x] Refresh data on a schedule based on when new data is available (refreshing all forecast sources hourly)\n* [x] Support multiple timezones as opposed to Pacific Time only\n* [x] New Surfline API\n* [x] Stop manually seeding the db and figure out a way to pull all spots from each data source and automatically associate them to a canonical spot record (probably using geocoding)\n* [x] Dark Theme\n* [x] Remove asset pipeline \u0026 process CSS w/ webpacker\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fswrobel%2Fmeta-surf-forecast","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fswrobel%2Fmeta-surf-forecast","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fswrobel%2Fmeta-surf-forecast/lists"}