seaports 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58b616c89654cb06c7c095c1106a16bc7164835cd6cedeaf40e10f22b23089be
4
- data.tar.gz: 9366dc14c643f638d73d6a288c2b10442698a03219afa0bc241a41e2e672b7f9
3
+ metadata.gz: 878655485e54ba5bb3f0ac454ff4ca7279d4725c8672325221cca9955b314d7c
4
+ data.tar.gz: c453a0e9b208fc027ea45d722ba13e7472a14785dfc534b4b936e367f204953a
5
5
  SHA512:
6
- metadata.gz: ae4bd49fb6adad08534857a84df567724347dc21d7d3ef7163f3760f9e0ac470b78e15db27ae35fbe60bdd8d63a25a1ab3d12da5cedbfdeb827ca6de45f8b5cc
7
- data.tar.gz: 134cc3f0980934cc76e251719a1ea47f0c142975c792485c4355e8ced35f25f3f9b47298c321c20f80e15ddbe80b22af0592ef212b6aaa32f0980c59c2b048e5
6
+ metadata.gz: 16c90431ed6304d4404ff2f02f816abf44bc1ed93fbb7bfbe453ab8303c662616ca27b4d8daf9a725d8edc27afa7622fdf596b99b51a0dc4fd75cd43416ab0c3
7
+ data.tar.gz: c9923419e7a8567153cd0c2e2643496cc3335089ca05dbcc2a3377132f8f7e96b7ed30f63cf66e1322dc927b189a6fbfa45a6066f8e5dd622ee65a84a1716630
data/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  Every entry below the first is a UN/LOCODE refresh, opened as a pull request by
4
4
  `.github/workflows/refresh.yml` and merged by a person.
5
5
 
6
+ ## 1.2.0 — 2026-10-20
7
+
8
+ Each port now carries its ISO 3166-2 subdivision, the path of every subdivision above it, and the principal (top-level) one: `Seaports.subdivision("GBFXT")` is `"GB-SFK"` (Suffolk), `Seaports.subdivision_path("FRSXB")` is `["FR-67", "FR-6AE", "FR-GES"]`, `Seaports.principal_subdivision("GBFXT")` is `"GB-ENG"`. Match on whichever level of the path other data uses: the principal one usually, but not for Alsace-Moselle, Irish counties or Indonesian provinces.
9
+
10
+ 14,567 of 17,527 ports have one: 13,149 from UN/LOCODE's subdivision column, 1,312 placed by their coordinates on Natural Earth's states-and-provinces map (Amsterdam), and 106 from a reviewed list for ports neither places (Valencia, Algeciras, Barcelona). Every code is checked against ISO 3166-2 (Debian iso-codes) at build time.
11
+
12
+ `Seaports::Port` gains three members, all optional, so `Port.new(locode:, name:, coordinates:)` still works. A positional pattern match on a three-element Port (`in [locode, name, coordinates]`) no longer matches; match by key instead.
13
+
14
+ Refreshes now ship a change at any level of a port's path, refuse a rebuild that moves more than 2% of ports to another principal subdivision, report overrides that have outlived their reason, and turn a failed download into a refusal issue rather than a failed build.
15
+
16
+ Same UN/LOCODE 2024-2 ports, names and positions as 1.1.0.
17
+
18
+ ## 1.1.0 — 2026-10-19
19
+
20
+ UN/LOCODE release unknown. 17527 sea ports (17520 before): 7 added, 0 removed, 1 renamed, 83 repositioned.
21
+
6
22
  ## 1.0.0 — 2026-08-21
7
23
 
8
24
  First release. Extracted from the Trackberry application, where this table
data/README.md CHANGED
@@ -6,9 +6,10 @@
6
6
  A locode in, a port name and a position out.
7
7
 
8
8
  ```ruby
9
- Seaports.name("CNXMG") # => "Xiamen Pt"
10
- Seaports.coordinates("AUBNE") # => { "lat" => -27.4667, "lng" => 153.0167 }
11
- Seaports.find("KRPUS") # => #<data Seaports::Port locode="KRPUS", name="Busan", ...>
9
+ Seaports.name("CNXMG") # => "Xiamen Pt"
10
+ Seaports.coordinates("AUBNE") # => { "lat" => -27.4667, "lng" => 153.0167 }
11
+ Seaports.principal_subdivision("DEHAM") # => "DE-HH"
12
+ Seaports.find("KRPUS") # => #<data Seaports::Port locode="KRPUS", name="Busan", ...>
12
13
  ```
13
14
 
14
15
  Tracking feeds speak in codes. An AIS provider names some port calls and sends
@@ -29,11 +30,12 @@ out of the default gems.
29
30
 
30
31
  ## What is in it
31
32
 
32
- Every entry UN/LOCODE classifies as a sea port: **17,520 ports**, 11,762 of them
33
- with published coordinates, built from **UN/LOCODE 2024-2**.
33
+ Every entry UN/LOCODE classifies as a sea port: **17,527 ports**, 11,820 of them
34
+ with published coordinates and 14,567 with a subdivision, built from
35
+ **UN/LOCODE 2024-2**.
34
36
 
35
37
  ```ruby
36
- Seaports.count # => 17520
38
+ Seaports.count # => 17527
37
39
  Seaports.all # => [#<data Seaports::Port ...>, ...]
38
40
  Seaports.data_release # => "2024-2"
39
41
  ```
@@ -53,6 +55,63 @@ Roughly a third of the table has no position published. That is a fact about
53
55
  the source, not a failure — the name is still worth having, and a map simply
54
56
  draws no marker.
55
57
 
58
+ ### Subdivisions
59
+
60
+ UN/LOCODE files each place under a subdivision of its country, which joined to
61
+ the country is an ISO 3166-2 code. It picks whatever level suits the country:
62
+ a state for Hamburg, a county for Felixstowe. Each port carries that code, the
63
+ **path** of every ISO subdivision above it, and the **principal subdivision**
64
+ at the top:
65
+
66
+ ```ruby
67
+ Seaports.subdivision("GBFXT") # => "GB-SFK" Suffolk
68
+ Seaports.principal_subdivision("GBFXT") # => "GB-ENG" England
69
+ Seaports.subdivision_path("FRSXB") # => ["FR-67", "FR-6AE", "FR-GES"] Bas-Rhin › Alsace › Grand Est
70
+ Seaports.principal_subdivision("DEHAM") # => "DE-HH" Hamburg, already top level
71
+ Seaports.principal_subdivision("ESVLC") # => "ES-VC" Comunitat Valenciana
72
+ ```
73
+
74
+ To join a port to other data, a public holiday calendar say, match on
75
+ whichever level of the path that data uses. The principal subdivision is
76
+ usually the one (German states, UK nations, Spanish communities), but ISO's
77
+ top level is not always where things are decided: Alsace-Moselle (`FR-6AE`)
78
+ keeps holidays the rest of Grand Est does not, Ireland's top level is its four
79
+ provinces rather than its counties, and Indonesia's is island groups (`ID-JW`
80
+ Java) rather than provinces (`ID-JK` Jakarta). The path carries every level, so
81
+ matching on any of it is always safe.
82
+
83
+ A port's subdivision comes from the first of three sources that has one, and
84
+ the table records which (`subdivision_source`):
85
+
86
+ | Source | Ports | How |
87
+ | --- | --- | --- |
88
+ | `override` | 106 | A reviewed list, `data/sources/subdivision_overrides.csv`, for ports neither of the others places: Valencia, Algeciras and Barcelona have no subdivision and no position in UN/LOCODE. Each row says where its code came from. |
89
+ | `unlocode` | 13,149 | UN/LOCODE's own subdivision column. |
90
+ | `boundaries` | 1,312 | The port's coordinates placed on [Natural Earth](https://www.naturalearthdata.com/)'s states-and-provinces map (public domain, pinned to one commit), or the nearest subdivision within 10 km when a waterfront position lands just offshore. Amsterdam is placed this way. |
91
+
92
+ Every code is checked against ISO 3166-2, and the path follows ISO's own
93
+ hierarchy, taken from
94
+ [Debian's iso-codes](https://salsa.debian.org/iso-codes-team/iso-codes). A code
95
+ whose chain is broken in that list is dropped rather than shipped without a
96
+ trustworthy top. Both maps are read when the table is built; nothing is
97
+ fetched at runtime.
98
+
99
+ 2,960 ports stay without one, mostly offshore fields and moorings, and ports in
100
+ countries with no regional distinctions worth drawing. To place more, run
101
+ `ruby script/suggest_subdivision_overrides.rb candidates.csv`: it searches each
102
+ port's name on OpenStreetMap's Nominatim and prints suggestions with the place
103
+ it matched, for a person to check before adding them to the overrides file. A
104
+ name search does find the wrong Puerto de Santa María. Place names in the
105
+ overrides file are © OpenStreetMap contributors, under the
106
+ [ODbL](https://www.openstreetmap.org/copyright).
107
+
108
+ Each refresh lists, for review, overrides that have outlived their reason: a
109
+ port that left UN/LOCODE, or one UN/LOCODE now places itself.
110
+
111
+ `Seaports::Port` gained `subdivision`, `principal_subdivision` and
112
+ `subdivision_path` in 1.2.0, all optional, so a `Port` built from locode, name
113
+ and coordinates as before still works.
114
+
56
115
  ### What is deliberately not in it
57
116
 
58
117
  Only entries UNECE marks with function `1`, "port", are kept. A road terminal,
@@ -73,6 +132,42 @@ from a three-character carrier code, which is a heuristic: a code that is not a
73
132
  locode tail could pair with a country to form a valid-looking locode for some
74
133
  inland village, and a table of ports is far less likely to name it.
75
134
 
135
+ ## What it costs
136
+
137
+ A lookup is a hash fetch. The table is parsed once, on the first lookup rather
138
+ than at require time, and after that nothing touches the disk again.
139
+
140
+ | | |
141
+ | --- | --- |
142
+ | lookup | **0.31 µs** — about 3.2M per second |
143
+ | miss | 0.28 µs |
144
+ | malformed input | 0.25 µs — rejected on shape before the table is consulted |
145
+ | first lookup, which parses the table | ~140 ms, once per process |
146
+ | resident table | 3.9 MB |
147
+
148
+ Measured on Ruby 4.0, Apple Silicon, against the shipped 468 KB table. Reading
149
+ the file off disk is 0.4 ms of that first 140 ms; the rest is CSV parsing and
150
+ building the 17,520 objects, so it is CPU rather than I/O.
151
+
152
+ There is no database, no ActiveRecord, and no query per lookup. That is worth
153
+ saying because the alternative approach in this corner of the ecosystem is to
154
+ ship a SQLite file and read it through ActiveRecord, which puts a second
155
+ database connection and a version-pinned ORM into the dependency graph of an
156
+ application that only wanted to turn `CNXMG` into `Xiamen Pt`. A CSV and a hash
157
+ answer the same question with one dependency and no connection.
158
+
159
+ Nothing here is cached, because there is nothing left to cache: the table
160
+ already is the cache. A `Rails.cache` round trip costs around a millisecond,
161
+ which is some three thousand times slower than the lookup it would be caching.
162
+
163
+ The one cost worth managing is that first 140 ms, and the fix is to move it off
164
+ the request path rather than to cache it:
165
+
166
+ ```ruby
167
+ # config/initializers/seaports.rb
168
+ Rails.application.config.after_initialize { Seaports.count }
169
+ ```
170
+
76
171
  ## How the table stays current
77
172
 
78
173
  UNECE republishes twice a year and does not announce it. A scheduled workflow