# Scheduled ferry crossings with operator-published fares

`orlero-ferry-crossings`, version 1.0.0. A [Frictionless Data
Package](https://datapackage.org): `datapackage.json`, a Table Schema per resource, CSV in
RFC 4180, and SHA-256 checksums.

2,709 scheduled ferry departures observed across 895 of 2,190 directed port pairs in 65 countries, each with the operator, the vessel and the fares that operator was publishing on the date it was read. Every row carries its source and how it was evidenced.

## Why this exists

Ferry is the mode with almost no open data behind it. Rail and coach operators file GTFS and
the aggregators read it; ferry operators overwhelmingly do not, so the timetables exist only
inside booking engines and the published fare is not written down anywhere a researcher can
cite. That absence has a cost you can measure: before this snapshot, every ferry edge in
Orlero's own network was inferred from operator service-area overlap, and the estate published
crossings between pairs of ports with no ferry between them.

This is what an observed answer looks like instead. 2,709
departures, each with the ship that sails it and the price its operator was charging, on named
dates, from ports named by the operator rather than by us. They are filed across
2,948 rows, because a departure from a port that also
answers to a city-wide aggregate is filed under both; see the limitations below.

## Coverage

| | |
|---|---:|
| Directed port pairs | 2,190 |
| Pairs with an observed sailing | 895 |
| Pairs asked about with no sailing on any probed date | 1,295 |
| Distinct sailings observed | 2,709 |
| Rows in `sailings.csv` holding them | 2,948 |
| Sailings carrying at least one fare | 2,709 |
| Individual fare quotes, per sailing | 5,997 |
| Individual fare quotes, per row | 6,569 |
| Operators | 120 |
| Distinct vessel strings | 357 |
| Of those, naming a ship rather than repeating the operator | 335 |
| Ports in the register | 1,440 |
| Ports appearing in a crossing | 600 |
| Of those, carrying a UN/LOCODE | 299 |
| Countries | 65 |

Probed dates: 2026-09-17, 2026-09-21, 2026-09-25, 2026-09-29. Read 2026-09-12T19:20:53.982Z.

## Method

1. **The graph.** `get_direct_connections_for_ports` is asked, by exact port name, where
   ships go from each port in the register. Asking by code returns the wrong port: the
   upstream resolver fuzzy matches code strings, and Barcelona's own code `BRC` answers with
   Bol in Croatia. The port the service answers *for* is checked against the port asked about,
   and a substitution is discarded rather than filed.
2. **The sailings.** Each pair the graph named is asked to `search_trips_v2` on four dates
   spread across the bookable window, in the exact port names the graph returned. Ferry
   inventory opens roughly two to three weeks out, and a single probe date confuses "no
   service today" with "no service on Tuesdays", so a crossing keeps the earliest probed date
   that answered.
3. **The rows.** Departure, arrival, operator, vessel and every quoted accommodation price are
   written down as returned. Nothing is normalised onto a house vocabulary and nothing is
   filled in.
4. **The joins.** Ports are matched to settlements for coordinates. Distance is computed.
   Counts are counted. All four of those are marked `derived` at the column level.

## Sources

| Source | Licence | Last refreshed upstream | What it contributes |
|---|---|---|---|
| [Ferryhopper MCP search_trips_v2](https://mcp.ferryhopper.com/mcp) | No published open licence | 2026-09-12 | departures, arrivals, vessels and the fares the operator was publishing on the probed date, read one port pair at a time |
| [Ferryhopper MCP get_direct_connections_for_ports](https://mcp.ferryhopper.com/mcp) | No published open licence | 2026-09-12 | the directed port pairs the aggregator declares are connected, whether or not a ship answered |
| [Ferryhopper MCP get_ports](https://mcp.ferryhopper.com/mcp) | No published open licence | 2026-09-20 | the port register: codes, names and the country each port sits in |

This table is rendered from `tools/data/sources.mjs`, the one place an upstream is described,
and `tools/data/check-provenance.mjs` fails the build if it stops matching the rows.

## Provenance, which is the point

Every row carries `source`, `source_url`, `fetched_at` and `evidence`.

| `evidence` | Means |
|---|---|
| `measured` | Read from an operator-published response about a specific service: a named ship, a departure time, a price the operator is charging. |
| `filed` | Read from a published register or connections graph. Declared by the operator or the aggregator, not observed running. |
| `derived` | Computed by `tools/data/ferry-package.mjs` from the above. |

`sailings.csv` is `measured` throughout, and that is the reason this dataset was built first.
`operators.csv` is `measured`: each row exists because that company's ships were seen.
`crossings.csv` is `measured` where a sailing was observed and `filed` where the connections
graph declares the pair and no ship answered on the probed dates. `ports.csv` is `filed`: a
register entry is a declaration.

A measured row can still carry a computed column, so every resource also has
**`derived_columns`**: the names of the columns in that row whose values were computed here.
A distance, a duration, a slug, a count and the currency label are all in it. Read that column
and you know exactly which numbers are ours.

## Conventions

- Dates and times ISO 8601 with offset, as published.
- Countries ISO 3166-1 alpha-2.
- Ports UN/LOCODE in `unlocode` where resolvable, with the raw operator code always kept in
  `port_code`. Join on `port_code`.
- Money ISO 4217, integer minor units, never a float.
- Coordinates WGS84 decimal degrees to 5 places.
- Durations and distances SI: seconds and metres.
- Missing data is an empty cell. Never 0, never `N/A`.
- CSV is UTF-8 without BOM, LF endings, RFC 4180 quoting.

## Known limitations, stated rather than buried

- **The currency label is ours, and it cannot be otherwise.** The upstream price field is
  `totalPriceInCents` and carries no currency code. That is not an omission that might be
  filled in later: the service's published output schema types `expectedPrice` as
  `{ totalPriceInCents: number }` with `additionalProperties: false`, so no currency can
  appear beside the amount in any response. Asking the same crossing with `accept-language` of
  `en`, `en-GB` and `de-DE` returns identical amounts, so it is one fixed currency rather
  than a localised one, and euro is much the best-supported reading of which: the retailer is a
  euro-area company, and its non-euro routes price in unround amounts (Red Funnel at 1638, Sindo
  at 2573) of the shape a conversion into euro leaves behind. It is still a reading, which is
  why `fare_currency` appears in `derived_columns` on every resource that carries a fare. The
  amounts are measured. The three letters beside them are not.

  What follows from that, for anyone using this file: **do not convert these amounts.** A figure
  whose unit is inferred should not be multiplied by a rate, because the result hides the
  inference instead of showing it. The estate's own ferry surfaces print them unconverted for
  exactly that reason.
- **`unlocode` is assigned syntactically, not looked up.** A code is treated as a UN/LOCODE
  when it is five characters, begins with a letter pair, and that pair equals the country the
  register gives the port. It is not checked against the UNECE list, which is not in this
  repository. 299 of the
  600 ports used by a crossing resolve this
  way. A consumer needing certified codes should join `port_code` against the UNECE list.
- **Coordinates are settlement centroids, not berths.** Athens resolves to Piraeus and Rome to
  Civitavecchia. The columns are named `place_latitude` and `place_longitude` for that
  reason, and `great_circle_distance_m` is the distance between settlements, not the sailed
  track. It is a lower bound on the real distance and it is marked derived.
- **An empty crossing is not proof of no service.** A pair with no sailing means no ship
  answered on four probed dates. Ferry timetables are the most irregular in transport, so read
  `probed_dates` before concluding anything from an absence.
- **The timestamps are local clock times wearing a Z, and `duration_s` no longer believes the
  Z.** Every departure and arrival upstream is stamped `Z` and none of them is a UTC instant.
  Singapore departures run 07:00 to 21:00 in this field, which is a Singapore timetable and not
  a UTC one; as UTC it would put the first ferry of the day at a quarter to four in the
  afternoon. The published values are recorded exactly as they came, because changing them
  would be inventing data, and each is the wall clock at **its own** port: `departure` at the
  origin, `arrival` at the destination. Join `ports.place_timezone` at both ends to resolve
  them.

  `duration_s` now does exactly that, and it is the column that changed in this version. It
  was previously the difference between the two stamps read as though both were UTC, which is
  wrong by the offset on any crossing that changes zone. Algeciras to Tanger Med, published
  01:30 to 02:00, was filed as a thirty minute crossing and is a ninety minute one.
  458 of
  2,948 rows, covering
  390 distinct sailings, have their two
  ports in different IANA zones, and every one of them was out by an hour in one direction or
  the other.

  The previous version published 575 here. That figure counted every pair whose two zones were
  not equal, which quietly included 121 rows where a port had no zone at all; an unknown is not
  a difference. Those eight ports now resolve, so the unknown bucket is empty and the figure
  above counts only genuine boundary crossings.
- **Every sailing now carries a duration.** The previous version left 31 rows empty and
  described them as an operator repeating its departure time. That was a misreading of the bug
  above. Twenty four Sindo Ferry rows publish Singapore 09:00 to Batam 09:00, which is sixty
  minutes across a one hour boundary and not zero, and six Balearia rows do the same across the
  Strait of Gibraltar; applying the zones resolves all thirty without a special case. The
  thirty-first, a 23:30 Red Funnel crossing arriving 00:30 with the date it left on, is a date
  rollover and is repaired as one. Both timestamps stay exactly as published in every case, and
  a duration is still only written where it could be worked out: 0
  rows carry an empty `duration_s`.
- **Operators are not deduplicated across spellings.** `CARONTE & TOURIST` and
  `CARONTE-TOURIST` both appear and are almost certainly one company. They stay as two rows,
  because merging them is a claim about corporate identity that nothing in the source supports,
  and a row in this file says what the operator published. Where two published names slug
  alike, the second in alphabetical order takes a numeric suffix on `operator_id`.
- **A sailing row is a row, not a ship.** `sailings.csv` holds
  2,948 rows and
  2,709 distinct operator trip identifiers,
  because one departure answers two crossings where a city-wide aggregate such as
  `ATHENS (ALL PORTS)` resolves to a named port that is itself a crossing. The key is
  `(crossing_id, sailing_id)`. Count departures by taking distinct `sailing_id`.
- **This is a snapshot, not a feed.** Fares move. A fare in this file is what the operator
  published at `fetched_at`, and it is a historical fact from the moment it was written.
- **No GTFS in this release.** GTFS needs a service calendar, and four probed dates do not
  support one. Emitting `calendar.txt` from this snapshot would assert a weekly pattern
  nobody observed. The `corridor-timetables` package, built from filed timetables, is where
  GTFS belongs.
- **No Parquet in this release.** The largest resource is
  2,709 rows, well under the ~50k threshold at which the
  house standard adds one.

## Files

```
datapackage.json                   Frictionless descriptor, with a hash per resource
data/crossings.csv                  2190 rows
data/sailings.csv                   2709 rows
data/operators.csv                   120 rows
data/ports.csv                      1440 rows
metadata/schema/*.schema.json      Table Schema per resource
metadata/dataset.jsonld            schema.org Dataset plus DCAT, the graph the page emits
metadata/croissant.json            MLCommons Croissant, for the Hugging Face mirror
metadata/summary.json              The figures above, so the page and the tests read one number each
checksums.txt                      SHA-256 per file
zenodo.json                        Deposit metadata, prepared and not yet submitted
publish.mjs                        Uploads the package to Zenodo. Reads ZENODO_TOKEN
LICENSE.txt                        CC BY 4.0, plus the EU database right
CHANGELOG.md
```

## Rebuilding it

```
node tools/data/ferry-package.mjs
```

Reads the four committed JSON snapshots under `apps/web/src/data`, rewrites everything under
`data-packages/ferry-crossings/`, and copies the package into
`apps/web/public/datasets/ferry-crossings/` so the download links on
`/data/ferry-crossings` serve the same bytes the checksums describe. Pass `--no-public` to
skip the copy and `--version=1.1.0` to cut a new version. It is deterministic: the same
inputs give byte-identical output, so a diff is a data change and nothing else.

To refresh the underlying snapshot first, see `tools/ferry/graph.mjs`,
`tools/ferry/ports.mjs` and `tools/ferry/sailings.mjs`.

## Citing it

> Orlero (2026). *Scheduled ferry crossings with operator-published fares* (Version 1.0.0) [Data set]. https://orlero.com/data/ferry-crossings

```bibtex
@dataset{orlero_ferry_crossings_1_0_0,
  author    = {{Orlero}},
  title     = {{Scheduled ferry crossings with operator-published fares}},
  year      = {2026},
  version   = {1.0.0},
  publisher = {Orlero},
  url       = {https://orlero.com/data/ferry-crossings},
  note      = {CC BY 4.0}
}
```

No DOI yet. One is minted when the Zenodo deposit is made; until then cite the URL and the
version. The BibTeX above gains a `doi` field and nothing else changes.

## Licence

CC BY 4.0, covering the EU sui generis database right on the same terms. See `LICENSE.txt`.
