seaports 1.0.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.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+
6
+ require_relative "../seaports"
7
+
8
+ module Seaports
9
+ # Rebuilds the shipped sea-port table from a published UN/LOCODE code list.
10
+ #
11
+ # The table is generated, not hand-maintained, and this is the only thing
12
+ # that should ever write it — so a refresh is `rake seaports:refresh` against
13
+ # a newer UNECE release rather than a diff someone edits by hand.
14
+ #
15
+ # UNECE's own distribution is a zip of headerless latin-1 CSVs, so the source
16
+ # here is the datasets/un-locode mirror, which republishes the same list with
17
+ # headers, UTF-8 and a machine-readable release number. It is a third party,
18
+ # which is why every refresh runs through Seaports::Diff before anything
19
+ # ships.
20
+ #
21
+ # Nothing breaks if a release is skipped: a port missing from our copy falls
22
+ # back to whatever the caller rendered before the table existed.
23
+ class Builder
24
+ SOURCE_URL = "https://raw.githubusercontent.com/datasets/un-locode/main/data/code-list.csv"
25
+ DATAPACKAGE_URL = "https://raw.githubusercontent.com/datasets/un-locode/main/datapackage.json"
26
+
27
+ HEADERS = %w[locode name lat lng].freeze
28
+
29
+ # The published list, straight off the network — or off disk, if what was
30
+ # handed over is a path. Both, because a rebuild from an already-downloaded
31
+ # release is how this is debugged, and it should not need a different
32
+ # entry point. Kept separate from parsing so every other path through this
33
+ # class can be tested with a string.
34
+ def self.fetch(source = SOURCE_URL)
35
+ return File.read(source) if File.exist?(source)
36
+
37
+ require "open-uri"
38
+ URI.parse(source).read
39
+ end
40
+
41
+ # Which UN/LOCODE edition the mirror is currently publishing, as UNECE
42
+ # names it: "2024.2.0" upstream becomes "2024-2" here. Nil rather than a
43
+ # guess if the mirror changes shape — a wrong release number is worse than
44
+ # an absent one, because it would be believed.
45
+ def self.published_release(source = DATAPACKAGE_URL)
46
+ version = JSON.parse(fetch(source))["version"].to_s
47
+ match = version.match(/\A(\d{4})\.(\d)/)
48
+ match && "#{match[1]}-#{match[2]}"
49
+ rescue StandardError
50
+ nil
51
+ end
52
+
53
+ def initialize(source)
54
+ @source = source
55
+ end
56
+
57
+ # [locode, name, lat, lng] per port, deduplicated and sorted by locode.
58
+ # Sorted because the file is committed: a stable order means a refresh
59
+ # diff shows what changed rather than the whole table moving.
60
+ def rows
61
+ @rows ||= CSV.parse(@source, headers: true)
62
+ .select { |row| seaport?(row) }
63
+ .map { |row| port_row(row) }
64
+ .uniq { |locode, *| locode }
65
+ .sort_by(&:first)
66
+ end
67
+
68
+ def write(path = Seaports::TABLE_PATH)
69
+ CSV.open(path, "w") do |csv|
70
+ csv << HEADERS
71
+ rows.each { |row| csv << row }
72
+ end
73
+ path
74
+ end
75
+
76
+ def located_count
77
+ rows.count { |row| !row[2].nil? }
78
+ end
79
+
80
+ private
81
+
82
+ # UNECE classifies every entry by function, and position 1 of that string
83
+ # is "port" — the only one a vessel can call at. Keeping the other 98k
84
+ # entries would mean a road terminal or a postal exchange could answer a
85
+ # lookup for a ship's port call, which is worse than not answering at all.
86
+ #
87
+ # `Change` marks an entry's fate in the current release; "X" is one being
88
+ # removed, and those are dropped.
89
+ def seaport?(row)
90
+ row["Function"].to_s.include?("1") &&
91
+ !row["Change"].to_s.include?("X") &&
92
+ !row["Location"].to_s.strip.empty? &&
93
+ !row["Name"].to_s.strip.empty?
94
+ end
95
+
96
+ def port_row(row)
97
+ lat, lng = decimal_degrees(row["Coordinates"])
98
+ ["#{row['Country']}#{row['Location']}".upcase, row["Name"].strip, lat, lng]
99
+ end
100
+
101
+ # UNECE writes coordinates as "2421N 11812E" — degrees and whole minutes,
102
+ # zero-padded, no separator. Rounded to four places, which is metres: the
103
+ # source only resolves to a minute (about a mile), and writing more digits
104
+ # than that would dress a coarse figure up as a survey.
105
+ def decimal_degrees(value)
106
+ match = value.to_s.strip.match(/\A(\d{2})(\d{2})([NS])\s+(\d{3})(\d{2})([EW])\z/)
107
+ return [nil, nil] if match.nil?
108
+
109
+ lat = (match[1].to_i + (match[2].to_i / 60.0)) * (match[3] == "S" ? -1 : 1)
110
+ lng = (match[4].to_i + (match[5].to_i / 60.0)) * (match[6] == "W" ? -1 : 1)
111
+ [lat.round(4), lng.round(4)]
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Seaports
6
+ # What a refresh actually changed, and whether it is safe to ship.
7
+ #
8
+ # The failure mode worth designing for is not "we missed a port". It is a bad
9
+ # upstream release — a truncated file, a changed column, a mirror that
10
+ # rebuilt itself wrong — landing in a gem that thousands of lookups trust. A
11
+ # human reading a pull request cannot eyeball 17,000 rows, so the gates below
12
+ # are the reading, and the summary is what is left to judge.
13
+ class Diff
14
+ # Ports large enough that their disappearance means the source broke, not
15
+ # that the world changed. Spread across continents so a regional truncation
16
+ # cannot slip through, and every one of them is a port a tracking feed
17
+ # names on an ordinary day.
18
+ ANCHORS = %w[
19
+ AEJEA AUBNE BEANR CNSGH CNXMG COCTG DEHAM ESALG ESVLC GBFXT
20
+ HKHKG KRPUS NLRTM NZTRG SGSIN USLAX
21
+ ].freeze
22
+
23
+ # Codes that must stay out. PAPCN is the Panama Canal, which UNECE files as
24
+ # an unverified road terminal — see the note in Seaports. If a future
25
+ # release reclassifies it as a port, that should be a decision someone
26
+ # makes, not a row that appears quietly.
27
+ EXCLUDED = %w[PAPCN].freeze
28
+
29
+ MINIMUM_COUNT = 15_000
30
+
31
+ # A real UNECE release adds and retires a handful of entries. Losing two
32
+ # percent of the table in one go is a broken source, not a busy half-year.
33
+ MAXIMUM_SHRINKAGE = 0.02
34
+
35
+ def self.between(before_path, after_path)
36
+ new(read(before_path), read(after_path))
37
+ end
38
+
39
+ def self.read(path)
40
+ CSV.foreach(path, headers: true).each_with_object({}) do |row, ports|
41
+ locode = row["locode"].to_s.strip.upcase
42
+ next if locode.empty?
43
+
44
+ ports[locode] = { name: row["name"].to_s.strip, point: point(row) }
45
+ end
46
+ end
47
+
48
+ def self.point(row)
49
+ lat = row["lat"].to_s.strip
50
+ lng = row["lng"].to_s.strip
51
+ return nil if lat.empty? || lng.empty?
52
+
53
+ [lat.to_f, lng.to_f]
54
+ end
55
+
56
+ def initialize(before, after)
57
+ @before = before
58
+ @after = after
59
+ end
60
+
61
+ def changed?
62
+ !(added.empty? && removed.empty? && renamed.empty? && repositioned.empty?)
63
+ end
64
+
65
+ def added
66
+ @added ||= (@after.keys - @before.keys).sort
67
+ end
68
+
69
+ def removed
70
+ @removed ||= (@before.keys - @after.keys).sort
71
+ end
72
+
73
+ # [locode, was, now] for a port that kept its code and changed its name.
74
+ def renamed
75
+ @renamed ||= common.filter_map do |locode|
76
+ was = @before[locode][:name]
77
+ now = @after[locode][:name]
78
+ [locode, was, now] if was != now
79
+ end
80
+ end
81
+
82
+ # Every kind of position change in one list, tagged, because they read
83
+ # differently downstream: a port that gains coordinates puts a new marker
84
+ # on a map, and one that loses them takes a marker away. Both are worth
85
+ # seeing before a release, and neither is a failure.
86
+ def repositioned
87
+ @repositioned ||= common.filter_map do |locode|
88
+ was = @before[locode][:point]
89
+ now = @after[locode][:point]
90
+ next if was == now
91
+
92
+ [locode, @after[locode][:name], (was.nil? ? :located : (now.nil? ? :unlocated : :moved))]
93
+ end
94
+ end
95
+
96
+ # Empty means the refresh may ship. Anything here is a reason it may not,
97
+ # written the way it should read in an issue.
98
+ def failures
99
+ [count_failure, shrinkage_failure, *anchor_failures, *excluded_failures].compact
100
+ end
101
+
102
+ def ok?
103
+ failures.empty?
104
+ end
105
+
106
+ def summary
107
+ [
108
+ headline,
109
+ failures.empty? ? nil : "**Blocked**\n\n#{failures.map { |line| "- #{line}" }.join("\n")}",
110
+ section("Removed", removed.map { |locode| "`#{locode}` #{@before[locode][:name]}" }, limit: nil),
111
+ section("Added", added.map { |locode| "`#{locode}` #{@after[locode][:name]}" }),
112
+ section("Renamed", renamed.map { |locode, was, now| "`#{locode}` #{was} → #{now}" }),
113
+ section("Coordinates", repositioned.map { |locode, name, kind| "`#{locode}` #{name} — #{POSITION_WORDS.fetch(kind)}" })
114
+ ].compact.join("\n\n")
115
+ end
116
+
117
+ POSITION_WORDS = {
118
+ located: "position published for the first time",
119
+ unlocated: "position withdrawn",
120
+ moved: "position moved"
121
+ }.freeze
122
+
123
+ # The one-line version, for a changelog entry that should not carry four
124
+ # hundred bullet points.
125
+ def headline
126
+ "#{@after.size} sea ports (#{@before.size} before): " \
127
+ "#{added.size} added, #{removed.size} removed, " \
128
+ "#{renamed.size} renamed, #{repositioned.size} repositioned."
129
+ end
130
+
131
+ private
132
+
133
+ def common
134
+ @common ||= (@before.keys & @after.keys).sort
135
+ end
136
+
137
+ def count_failure
138
+ return nil if @after.size >= MINIMUM_COUNT
139
+
140
+ "Only #{@after.size} ports in the rebuilt table, below the floor of #{MINIMUM_COUNT}."
141
+ end
142
+
143
+ def shrinkage_failure
144
+ return nil if @before.empty?
145
+
146
+ shrinkage = (@before.size - @after.size).to_f / @before.size
147
+ return nil if shrinkage <= MAXIMUM_SHRINKAGE
148
+
149
+ "The table shrank by #{(shrinkage * 100).round(1)}%, past the #{(MAXIMUM_SHRINKAGE * 100).round}% limit."
150
+ end
151
+
152
+ def anchor_failures
153
+ ANCHORS.reject { |locode| @after.key?(locode) }
154
+ .map { |locode| "`#{locode}` is gone, and a port that large does not disappear." }
155
+ end
156
+
157
+ def excluded_failures
158
+ EXCLUDED.select { |locode| @after.key?(locode) }
159
+ .map { |locode| "`#{locode}` is now classified as a sea port upstream, which is a decision to make deliberately." }
160
+ end
161
+
162
+ # Removals are listed in full — they are the changes that break a caller.
163
+ # Everything else is capped, because a release that adds four hundred ports
164
+ # should not bury the four that were taken away.
165
+ def section(title, lines, limit: 40)
166
+ return nil if lines.empty?
167
+
168
+ shown = limit.nil? ? lines : lines.first(limit)
169
+ body = shown.map { |line| "- #{line}" }.join("\n")
170
+ body += "\n- …and #{lines.size - shown.size} more" if lines.size > shown.size
171
+ "**#{title}** (#{lines.size})\n\n#{body}"
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seaports
4
+ VERSION = "1.0.0"
5
+
6
+ # The UN/LOCODE edition the shipped table was built from. UNECE republishes
7
+ # twice a year and names each release by year and half — "2024-2" is the
8
+ # second release of 2024. A gem version says what the code does; this says
9
+ # how old the data is, which is the question a caller actually has.
10
+ DATA_RELEASE = "2024-2"
11
+ end
data/lib/seaports.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ require_relative "seaports/version"
6
+
7
+ # UN/LOCODE sea ports: a locode in, a port name and position out.
8
+ #
9
+ # Tracking feeds speak in codes. A provider names some port calls and sends
10
+ # only a code for others, so a stop that mattered arrives as a bare "XMG" — a
11
+ # real, load-bearing fact about a voyage written in a vocabulary nobody reads.
12
+ # UNECE publishes the table that turns it back into "Xiamen Pt", and publishes
13
+ # coordinates alongside, so one dataset answers both "where is this?" and
14
+ # "where do I draw it?".
15
+ #
16
+ # The table ships inside the gem rather than being fetched: it is reference
17
+ # data that changes twice a year, and a port name should not depend on a
18
+ # network. `rake seaports:refresh` regenerates it; nothing else should write it.
19
+ #
20
+ # Seaports.find("CNXMG") # => #<data Seaports::Port locode="CNXMG", ...>
21
+ # Seaports.name("AUBNE") # => "Brisbane"
22
+ # Seaports.coordinates("AUBNE") # => { "lat" => -27.4667, "lng" => 153.0167 }
23
+ #
24
+ # Coverage is sea ports only, which is what a vessel's port call is, and
25
+ # UNECE's own classification decides what counts. The notable absence is
26
+ # PAPCN, "Panama Canal", which some feeds do send: UNECE files it as a road
27
+ # terminal with status RL — "recognised location", its weakest, meaning no
28
+ # national authority has approved it and its functions were never verified.
29
+ # That is the default bucket for an unchecked entry rather than a ruling, but
30
+ # the substance holds anyway, because a canal is a waterway and not a port. The
31
+ # ports at either end are coded properly (PABLB Balboa, PACTB Cristóbal), so a
32
+ # canal transit is better rendered from the country than from a port lookup.
33
+ #
34
+ # Keeping the filter narrow also guards anyone rebuilding a five-character
35
+ # locode from a three-character carrier code, which is a heuristic: a code that
36
+ # is not a locode tail could pair with a country to form a valid-looking locode
37
+ # for some inland village, and a table of ports is far less likely to name it.
38
+ module Seaports
39
+ TABLE_PATH = File.expand_path("../data/un_locode_seaports.csv", __dir__)
40
+
41
+ Port = Data.define(:locode, :name, :coordinates)
42
+
43
+ class << self
44
+ # Nil for anything the table does not hold, which callers must handle:
45
+ # ~17k ports is most of the world's, not all of it, and an unknown code is
46
+ # the ordinary case rather than an error.
47
+ def find(locode)
48
+ key = normalize(locode)
49
+ return nil if key.nil?
50
+
51
+ table[key]
52
+ end
53
+
54
+ def name(locode)
55
+ find(locode)&.name
56
+ end
57
+
58
+ # { "lat" => Float, "lng" => Float }, or nil. String keys because the usual
59
+ # destination is JSON on its way to a map. Roughly a third of the table has
60
+ # no coordinates published, so a named port with no position is normal.
61
+ def coordinates(locode)
62
+ find(locode)&.coordinates
63
+ end
64
+
65
+ # Every port in the table, ordered by locode. Materialised on each call
66
+ # rather than handed out from the cache, so a caller cannot mutate the
67
+ # table everyone else reads.
68
+ def all
69
+ table.values.dup
70
+ end
71
+
72
+ # How many ports the table holds — the guard that a truncated or
73
+ # half-written CSV is caught by a test rather than by a production page.
74
+ def count
75
+ table.size
76
+ end
77
+
78
+ # Which UN/LOCODE edition the shipped table came from, for a caller that
79
+ # wants to assert its data is not years old.
80
+ def data_release
81
+ DATA_RELEASE
82
+ end
83
+
84
+ private
85
+
86
+ # A locode is five characters: a two-letter country and a three-character
87
+ # place. Anything else is not one, and guessing at it would mean answering
88
+ # a question that was not asked.
89
+ def normalize(locode)
90
+ key = locode.to_s.strip.upcase
91
+ key.match?(/\A[A-Z]{2}[A-Z0-9]{3}\z/) ? key : nil
92
+ end
93
+
94
+ # Read once, on the first lookup rather than at require time: most
95
+ # processes never look a port up, and the ones that do can afford the parse.
96
+ def table
97
+ @table || LOAD_LOCK.synchronize { @table ||= load_table }
98
+ end
99
+
100
+ LOAD_LOCK = Mutex.new
101
+
102
+ def load_table
103
+ CSV.foreach(TABLE_PATH, headers: true).each_with_object({}) do |row, ports|
104
+ locode = normalize(row["locode"])
105
+ next if locode.nil? || blank?(row["name"])
106
+
107
+ ports[locode] = Port.new(locode: locode, name: row["name"], coordinates: point(row))
108
+ end
109
+ end
110
+
111
+ def point(row)
112
+ return nil if blank?(row["lat"]) || blank?(row["lng"])
113
+
114
+ { "lat" => row["lat"].to_f, "lng" => row["lng"].to_f }
115
+ end
116
+
117
+ def blank?(value)
118
+ value.to_s.strip.empty?
119
+ end
120
+ end
121
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seaports
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Édouard Brière
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.3'
26
+ description: |
27
+ Every sea port UNECE publishes a UN/LOCODE for, as a lookup table with no
28
+ dependencies and no network calls. Turns the bare codes in an AIS or
29
+ carrier tracking feed back into port names and coordinates. The table is
30
+ regenerated from each UN/LOCODE release and shipped inside the gem.
31
+ email:
32
+ - edouard.briere@gmail.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - data/un_locode_seaports.csv
41
+ - lib/seaports.rb
42
+ - lib/seaports/builder.rb
43
+ - lib/seaports/diff.rb
44
+ - lib/seaports/version.rb
45
+ homepage: https://github.com/Trackberry/seaports
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ source_code_uri: https://github.com/Trackberry/seaports
50
+ changelog_uri: https://github.com/Trackberry/seaports/blob/main/CHANGELOG.md
51
+ bug_tracker_uri: https://github.com/Trackberry/seaports/issues
52
+ rubygems_mfa_required: 'true'
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.2.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.9
68
+ specification_version: 4
69
+ summary: 'UN/LOCODE sea ports: a locode in, a port name and position out.'
70
+ test_files: []