overture_maps 0.1.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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +416 -0
  5. data/app/controllers/overture_maps/application_controller.rb +28 -0
  6. data/app/controllers/overture_maps/attribution_controller.rb +15 -0
  7. data/app/controllers/overture_maps/features_controller.rb +125 -0
  8. data/app/controllers/overture_maps/search_controller.rb +31 -0
  9. data/app/controllers/overture_maps/tiles_controller.rb +85 -0
  10. data/config/routes.rb +13 -0
  11. data/exe/overture-maps-mcp +6 -0
  12. data/lib/generators/overture_maps/address_generator.rb +19 -0
  13. data/lib/generators/overture_maps/base_features_generator.rb +19 -0
  14. data/lib/generators/overture_maps/base_generator.rb +22 -0
  15. data/lib/generators/overture_maps/building_generator.rb +19 -0
  16. data/lib/generators/overture_maps/division_generator.rb +19 -0
  17. data/lib/generators/overture_maps/install_generator.rb +42 -0
  18. data/lib/generators/overture_maps/place_generator.rb +19 -0
  19. data/lib/generators/overture_maps/transportation_generator.rb +20 -0
  20. data/lib/generators/templates/address_migration.rb.tt +25 -0
  21. data/lib/generators/templates/address_model.rb.tt +4 -0
  22. data/lib/generators/templates/base_feature_model.rb.tt +4 -0
  23. data/lib/generators/templates/base_features_migration.rb.tt +27 -0
  24. data/lib/generators/templates/building_migration.rb.tt +24 -0
  25. data/lib/generators/templates/building_model.rb.tt +4 -0
  26. data/lib/generators/templates/category_migration.rb.tt +15 -0
  27. data/lib/generators/templates/category_model.rb.tt +4 -0
  28. data/lib/generators/templates/connector_model.rb.tt +4 -0
  29. data/lib/generators/templates/division_migration.rb.tt +31 -0
  30. data/lib/generators/templates/division_model.rb.tt +4 -0
  31. data/lib/generators/templates/imported_areas_migration.rb.tt +20 -0
  32. data/lib/generators/templates/place_migration.rb.tt +30 -0
  33. data/lib/generators/templates/place_model.rb.tt +4 -0
  34. data/lib/generators/templates/postgis_migration.rb.tt +5 -0
  35. data/lib/generators/templates/segment_model.rb.tt +4 -0
  36. data/lib/generators/templates/transportation_migration.rb.tt +38 -0
  37. data/lib/overture_maps/attribution.rb +66 -0
  38. data/lib/overture_maps/bounding_box.rb +80 -0
  39. data/lib/overture_maps/changelog.rb +70 -0
  40. data/lib/overture_maps/configuration.rb +43 -0
  41. data/lib/overture_maps/database.rb +74 -0
  42. data/lib/overture_maps/division_search.rb +44 -0
  43. data/lib/overture_maps/engine.rb +32 -0
  44. data/lib/overture_maps/geometry_parser.rb +41 -0
  45. data/lib/overture_maps/gers.rb +30 -0
  46. data/lib/overture_maps/import/downloader.rb +248 -0
  47. data/lib/overture_maps/import/location_based_runner.rb +176 -0
  48. data/lib/overture_maps/import/parquet_reader.rb +32 -0
  49. data/lib/overture_maps/import/record_mapper.rb +182 -0
  50. data/lib/overture_maps/import/runner.rb +125 -0
  51. data/lib/overture_maps/import.rb +27 -0
  52. data/lib/overture_maps/mcp_server.rb +222 -0
  53. data/lib/overture_maps/models/address.rb +32 -0
  54. data/lib/overture_maps/models/base.rb +39 -0
  55. data/lib/overture_maps/models/base_feature.rb +31 -0
  56. data/lib/overture_maps/models/building.rb +35 -0
  57. data/lib/overture_maps/models/category.rb +32 -0
  58. data/lib/overture_maps/models/connector.rb +11 -0
  59. data/lib/overture_maps/models/division.rb +43 -0
  60. data/lib/overture_maps/models/imported_area.rb +42 -0
  61. data/lib/overture_maps/models/place.rb +43 -0
  62. data/lib/overture_maps/models/segment.rb +22 -0
  63. data/lib/overture_maps/models.rb +25 -0
  64. data/lib/overture_maps/query.rb +168 -0
  65. data/lib/overture_maps/query_engine.rb +268 -0
  66. data/lib/overture_maps/releases.rb +74 -0
  67. data/lib/overture_maps/storage.rb +121 -0
  68. data/lib/overture_maps/syncer.rb +99 -0
  69. data/lib/overture_maps/util.rb +30 -0
  70. data/lib/overture_maps/version.rb +3 -0
  71. data/lib/overture_maps.rb +42 -0
  72. data/lib/tasks/overture_maps.rake +554 -0
  73. metadata +301 -0
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OvertureMaps
4
+ module Import
5
+ # Orchestrates a location-based import: resolves the location (bbox
6
+ # string or division name) to a bounding box, downloads or reuses a
7
+ # bbox-filtered parquet extract per feature type, and streams it into the
8
+ # target model via Runner.
9
+ #
10
+ # Themes map one or more feature types to models:
11
+ # models: { "segment" => OvertureSegment, "connector" => OvertureConnector }
12
+ # For single-model themes, model_class: alone is enough.
13
+ #
14
+ # This class never prompts or exits. Interactive behavior is injected by
15
+ # the rake layer through callbacks:
16
+ # select_division: ->(results) { result or nil } nil aborts
17
+ # confirm_cached: ->(path) { :use | :refresh | :abort }
18
+ # Without callbacks (library/job usage) it picks the largest matching
19
+ # division and reuses exact cache hits.
20
+ class LocationBasedRunner
21
+ attr_reader :theme, :location, :imported_count, :error_count, :errors, :bbox
22
+
23
+ def initialize(theme:, location:, model_class: nil, models: nil, categories: nil,
24
+ batch_size: nil, release: nil, output_dir: nil,
25
+ select_division: nil, confirm_cached: nil)
26
+ @theme = theme
27
+ @location = location
28
+ @models = resolve_models(model_class, models)
29
+ @categories = categories
30
+ @batch_size = batch_size
31
+ @release = Releases.validate!(release || Releases.current)
32
+ @output_dir = output_dir
33
+ @select_division = select_division
34
+ @confirm_cached = confirm_cached
35
+ @imported_count = 0
36
+ @error_count = 0
37
+ @errors = []
38
+ end
39
+
40
+ def run
41
+ @bbox = resolve_bbox
42
+ log "Bounding box: #{bbox} (release #{@release})"
43
+
44
+ @models.each { |type, model_class| import_type(type, model_class) }
45
+ self
46
+ end
47
+
48
+ def success?
49
+ @error_count.zero?
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_models(model_class, models)
55
+ return models if models&.any?
56
+ raise ArgumentError, "provide model_class: or models:" unless model_class
57
+
58
+ types = Downloader.types_for_theme(theme)
59
+ raise Error, "no types for theme: #{theme}" if types.empty?
60
+
61
+ types.to_h { |t| [t, model_class] }
62
+ end
63
+
64
+ def resolve_bbox
65
+ return location if location.is_a?(BoundingBox)
66
+
67
+ parsed = BoundingBox.parse(location)
68
+ return parsed if parsed
69
+
70
+ results = DivisionSearch.search(query: location, release: @release)
71
+ raise Error, "no divisions found matching #{location.inspect}" if results.empty?
72
+
73
+ division = choose_division(results)
74
+ raise CancelledError, "import cancelled" unless division
75
+
76
+ log "Using #{division[:name]} (#{division[:subtype]}, #{[division[:country], division[:region]].compact.join(" / ")})"
77
+ division[:bbox]
78
+ end
79
+
80
+ def choose_division(results)
81
+ return results.first if results.length == 1 || @select_division.nil?
82
+
83
+ @select_division.call(results)
84
+ end
85
+
86
+ def import_type(type, model_class)
87
+ downloader = Downloader.new(theme: theme, type: type, release: @release, output_dir: @output_dir)
88
+ path = resolve_extract(downloader)
89
+ unless path
90
+ log "#{theme}/#{type}: no data found"
91
+ return
92
+ end
93
+
94
+ runner = Runner.new(model_class: model_class, theme: theme, type: type,
95
+ batch_size: @batch_size, release: @release)
96
+ reader = ParquetReader.new(theme: theme)
97
+ log "#{theme}/#{type}: importing #{File.basename(path)}..."
98
+ runner.import_from_reader(reader, source: path, filter: category_filter)
99
+
100
+ @imported_count += runner.imported_count
101
+ @error_count += runner.error_count
102
+ @errors.concat(runner.errors)
103
+ log "#{theme}/#{type}: imported #{runner.imported_count}, errors #{runner.error_count}"
104
+
105
+ record_imported_area(type, model_class, runner)
106
+ end
107
+
108
+ # Bookkeeping for overture_maps:sync. Skipped silently when the
109
+ # tracking table hasn't been migrated yet.
110
+ def record_imported_area(type, model_class, runner)
111
+ return unless runner.imported_count.positive?
112
+ return unless Models::ImportedArea.table_exists?
113
+
114
+ Models::ImportedArea.record!(
115
+ theme: theme, feature_type: type, model_class: model_class,
116
+ bbox: bbox, release: @release,
117
+ records_count: runner.imported_count
118
+ )
119
+ rescue StandardError => e
120
+ log "sync tracking skipped: #{e.message}"
121
+ end
122
+
123
+ def resolve_extract(downloader)
124
+ cached = downloader.cached_extract(bbox)
125
+
126
+ if cached
127
+ case cached_decision(cached)
128
+ when :use
129
+ log "Reusing cached extract #{File.basename(cached)}"
130
+ return cached
131
+ when :abort
132
+ raise CancelledError, "import cancelled"
133
+ else
134
+ File.delete(cached)
135
+ end
136
+ end
137
+
138
+ downloader.extract_bbox(bbox)
139
+ end
140
+
141
+ def cached_decision(path)
142
+ return :use unless @confirm_cached
143
+
144
+ @confirm_cached.call(path)
145
+ end
146
+
147
+ # Matches leaf categories against categories.primary and
148
+ # categories.alternate. Taxonomy groups (e.g. "eat_and_drink",
149
+ # "restaurant") expand to their leaves when the categories table has
150
+ # been populated (rails overture_maps:categories:populate).
151
+ def category_filter
152
+ return nil unless @categories&.any?
153
+
154
+ wanted = expand_categories(@categories.map(&:to_s))
155
+ lambda do |record|
156
+ cats = record["categories"]
157
+ primary = cats.is_a?(Hash) ? cats["primary"] : record["basic_category"]
158
+ alternates = cats.is_a?(Hash) ? Array(cats["alternate"]) : []
159
+
160
+ wanted.any? { |w| primary == w || alternates.include?(w) }
161
+ end
162
+ end
163
+
164
+ def expand_categories(names)
165
+ Models::Category.expand(names)
166
+ rescue StandardError
167
+ names
168
+ end
169
+
170
+ def log(message)
171
+ logger = OvertureMaps.configuration.logger
172
+ logger ? logger.info(message) : puts(message)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parquet"
4
+
5
+ module OvertureMaps
6
+ module Import
7
+ # Reads local Overture parquet files row by row. Remote access and
8
+ # spatial filtering live in Downloader/QueryEngine — by the time a file
9
+ # reaches this class it is already a local extract.
10
+ class ParquetReader
11
+ THEMES = Downloader::THEMES
12
+
13
+ attr_reader :theme
14
+
15
+ def initialize(theme: nil)
16
+ @theme = theme
17
+ end
18
+
19
+ def each_record(source:, &block)
20
+ raise ArgumentError, "source must be a local file path" unless source.is_a?(String) && File.exist?(source)
21
+
22
+ Parquet.each_row(source, &block)
23
+ end
24
+
25
+ def record_count(source:)
26
+ raise ArgumentError, "source must be a local file path" unless source.is_a?(String) && File.exist?(source)
27
+
28
+ Parquet.metadata(source)["num_rows"]
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OvertureMaps
4
+ module Import
5
+ # Maps raw Overture records (nested structs from parquet) onto flat model
6
+ # attributes. Every record gets the same key set — heterogeneous keys make
7
+ # ActiveRecord's insert_all/upsert_all reject the whole batch.
8
+ class RecordMapper
9
+ def self.for(theme:, model_class:, type: nil, release: nil)
10
+ new(theme: theme, model_class: model_class, type: type, release: release)
11
+ end
12
+
13
+ attr_reader :theme, :type, :model_class, :release
14
+
15
+ def initialize(theme:, model_class:, type: nil, release: nil)
16
+ @theme = theme
17
+ @type = type
18
+ @model_class = model_class
19
+ @release = release
20
+ @columns = model_class.column_names
21
+ end
22
+
23
+ # Returns attributes with a stable key set, or nil if the record can't
24
+ # be mapped (caller records the error).
25
+ def call(record)
26
+ attrs = base_attributes(record)
27
+ attrs.merge!(theme_attributes(record))
28
+ normalize(attrs)
29
+ end
30
+
31
+ # The attribute keys every mapped record will contain.
32
+ def key_set
33
+ @key_set ||= begin
34
+ sample = base_attributes({}).merge(theme_attributes({}))
35
+ keys = sample.keys.select { |k| @columns.include?(k.to_s) }
36
+ keys | %i[id geometry]
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def base_attributes(record)
43
+ {
44
+ id: record["id"],
45
+ geometry: record["geometry"],
46
+ names: record["names"],
47
+ name: record.dig("names", "primary"),
48
+ sources: record["sources"],
49
+ overture_release: release,
50
+ created_at: now,
51
+ updated_at: now
52
+ }
53
+ end
54
+
55
+ def theme_attributes(record)
56
+ case theme
57
+ when "places" then place_attributes(record)
58
+ when "buildings" then building_attributes(record)
59
+ when "addresses" then address_attributes(record)
60
+ when "divisions" then division_attributes(record)
61
+ when "transportation" then transportation_attributes(record)
62
+ when "base" then base_theme_attributes(record)
63
+ else {}
64
+ end
65
+ end
66
+
67
+ def place_attributes(record)
68
+ {
69
+ # categories is deprecated in favor of basic_category + taxonomy
70
+ # (~Sep 2026); read both shapes so imports survive the cutover.
71
+ categories: record["categories"] || record["taxonomy"],
72
+ primary_category: record.dig("categories", "primary") || record["basic_category"],
73
+ brands: record["brand"],
74
+ addresses: record["addresses"],
75
+ confidence: record["confidence"],
76
+ operating_status: record["operating_status"],
77
+ websites: record["websites"],
78
+ socials: record["socials"],
79
+ emails: record["emails"],
80
+ phones: record["phones"],
81
+ country: first_address_value(record, "country")
82
+ }
83
+ end
84
+
85
+ def building_attributes(record)
86
+ {
87
+ subtype: record["subtype"],
88
+ building_class: record["class"],
89
+ height: record["height"],
90
+ num_floors: record["num_floors"],
91
+ level: record["level"],
92
+ is_underground: record["is_underground"]
93
+ }
94
+ end
95
+
96
+ def address_attributes(record)
97
+ levels = Array(record["address_levels"])
98
+ {
99
+ number: record["number"],
100
+ street: record["street"],
101
+ unit: record["unit"],
102
+ postcode: record["postcode"],
103
+ country: record["country"],
104
+ postal_city: record["postal_city"],
105
+ address_levels: record["address_levels"],
106
+ # Best-effort convenience columns; ordering of address_levels is
107
+ # country-dependent, the authoritative data is the jsonb column.
108
+ region: levels.dig(0, "value"),
109
+ locality: record["postal_city"] || levels.dig(1, "value")
110
+ }
111
+ end
112
+
113
+ # division_area records: the geocodable territory of a division.
114
+ def division_attributes(record)
115
+ bbox = record["bbox"] || {}
116
+ {
117
+ division_id: record["division_id"],
118
+ subtype: record["subtype"],
119
+ division_class: record["class"],
120
+ country: record["country"],
121
+ region: record["region"],
122
+ is_land: record["is_land"],
123
+ is_territorial: record["is_territorial"],
124
+ bbox_xmin: bbox["xmin"],
125
+ bbox_xmax: bbox["xmax"],
126
+ bbox_ymin: bbox["ymin"],
127
+ bbox_ymax: bbox["ymax"]
128
+ }
129
+ end
130
+
131
+ def transportation_attributes(record)
132
+ return {} unless type == "segment" # connectors carry only id + geometry
133
+
134
+ {
135
+ subtype: record["subtype"],
136
+ segment_class: record["class"],
137
+ subclass: record["subclass"],
138
+ connectors: record["connectors"],
139
+ routes: record["routes"],
140
+ speed_limits: record["speed_limits"],
141
+ access_restrictions: record["access_restrictions"],
142
+ prohibited_transitions: record["prohibited_transitions"],
143
+ road_surface: record["road_surface"],
144
+ road_flags: record["road_flags"],
145
+ destinations: record["destinations"]
146
+ }
147
+ end
148
+
149
+ def base_theme_attributes(record)
150
+ {
151
+ feature_type: type,
152
+ subtype: record["subtype"],
153
+ feature_class: record["class"],
154
+ surface: record["surface"],
155
+ elevation: record["elevation"],
156
+ level: record["level"],
157
+ wikidata: record["wikidata"],
158
+ source_tags: record["source_tags"]
159
+ }
160
+ end
161
+
162
+ def first_address_value(record, key)
163
+ addresses = record["addresses"]
164
+ return nil unless addresses.is_a?(Array) && addresses.first.is_a?(Hash)
165
+
166
+ addresses.first[key]
167
+ end
168
+
169
+ # Keep only real columns, ensure every key from the key set is present
170
+ # so batches stay homogeneous.
171
+ def normalize(attrs)
172
+ normalized = {}
173
+ key_set.each { |key| normalized[key] = attrs[key] }
174
+ normalized
175
+ end
176
+
177
+ def now
178
+ @now ||= Time.now.utc
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rgeo"
4
+ require "rgeo/geo_json"
5
+ require "json"
6
+
7
+ module OvertureMaps
8
+ module Import
9
+ # Batches records into idempotent upserts. Re-running an import updates
10
+ # existing rows (keyed on the GERS id primary key) instead of failing.
11
+ class Runner
12
+ MAX_STORED_ERRORS = 50
13
+ PROGRESS_INTERVAL = 10_000
14
+
15
+ attr_reader :model_class, :theme, :type, :batch_size, :imported_count, :error_count, :errors
16
+
17
+ def initialize(model_class:, theme: nil, type: nil, batch_size: nil, mapper: nil,
18
+ release: nil, progress_every: PROGRESS_INTERVAL)
19
+ @model_class = model_class
20
+ @theme = theme
21
+ @type = type
22
+ @release = release
23
+ @batch_size = batch_size || OvertureMaps.configuration.batch_size
24
+ @mapper = mapper
25
+ @progress_every = progress_every
26
+ @imported_count = 0
27
+ @error_count = 0
28
+ @errors = []
29
+ end
30
+
31
+ # Imports from anything that yields raw Overture record hashes.
32
+ def import_from_records(records, transform: nil, filter: nil)
33
+ batch = []
34
+
35
+ records.each do |record|
36
+ next if filter && !filter.call(record)
37
+
38
+ attrs = transform_record(record, transform)
39
+ next unless attrs
40
+
41
+ batch << attrs
42
+ if batch.length >= batch_size
43
+ flush_records(batch)
44
+ batch = []
45
+ end
46
+ end
47
+
48
+ flush_records(batch) if batch.any?
49
+ self
50
+ end
51
+
52
+ def import_from_reader(reader, source:, transform: nil, filter: nil)
53
+ import_from_records(reader.enum_for(:each_record, source: source), transform: transform, filter: filter)
54
+ end
55
+
56
+ def import_from_file(path, theme: nil, transform: nil, filter: nil)
57
+ reader = ParquetReader.new(theme: theme)
58
+ import_from_reader(reader, source: path, transform: transform, filter: filter)
59
+ end
60
+
61
+ def success?
62
+ @error_count.zero?
63
+ end
64
+
65
+ private
66
+
67
+ def transform_record(record, transform)
68
+ attrs = transform ? transform.call(record) : default_mapper.call(record)
69
+ return nil unless attrs
70
+
71
+ if attrs[:geometry] || attrs["geometry"]
72
+ key = attrs.key?(:geometry) ? :geometry : "geometry"
73
+ attrs[key] = parse_geometry(attrs[key])
74
+ end
75
+ attrs
76
+ rescue StandardError => e
77
+ record_error("mapping failed: #{e.message}", record["id"])
78
+ nil
79
+ end
80
+
81
+ def default_mapper
82
+ @default_mapper ||= @mapper || RecordMapper.for(
83
+ theme: theme, type: type, model_class: model_class, release: @release
84
+ )
85
+ end
86
+
87
+ def flush_records(records)
88
+ return if records.empty?
89
+
90
+ model_class.upsert_all(records, unique_by: model_class.primary_key)
91
+ @imported_count += records.length
92
+ log_progress(records.length)
93
+ rescue StandardError
94
+ # Isolate bad rows without abandoning the batch.
95
+ records.each do |record|
96
+ model_class.upsert_all([record], unique_by: model_class.primary_key)
97
+ @imported_count += 1
98
+ rescue StandardError => e
99
+ record_error(e.message, record[:id] || record["id"])
100
+ end
101
+ end
102
+
103
+ def record_error(message, record_id = nil)
104
+ @error_count += 1
105
+ @errors << { error: message, record_id: record_id } if @errors.length < MAX_STORED_ERRORS
106
+ end
107
+
108
+ # A line every @progress_every rows so long imports show life.
109
+ def log_progress(batch_length)
110
+ return unless @progress_every&.positive?
111
+ return unless (@imported_count / @progress_every) > ((@imported_count - batch_length) / @progress_every)
112
+
113
+ logger = OvertureMaps.configuration.logger
114
+ message = " #{@imported_count} rows..."
115
+ logger ? logger.info(message) : puts(message)
116
+ end
117
+
118
+ # A geometry that fails to parse skips that record — it must never
119
+ # abort the import loop (see transform_record's rescue).
120
+ def parse_geometry(geom)
121
+ GeometryParser.parse(geom)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OvertureMaps
4
+ module Import
5
+ class Error < OvertureMaps::Error; end
6
+ end
7
+ end
8
+
9
+ require "overture_maps/import/downloader"
10
+ require "overture_maps/import/parquet_reader"
11
+ require "overture_maps/import/record_mapper"
12
+ require "overture_maps/import/runner"
13
+ require "overture_maps/import/location_based_runner"
14
+
15
+ module OvertureMaps
16
+ module Import
17
+ # Programmatic file import. The transform may be given as a keyword or a
18
+ # block; it receives each raw record and returns attributes (or nil to
19
+ # skip the record).
20
+ def self.run!(theme:, model_class:, file_path:, batch_size: nil, transform: nil, release: nil, &block)
21
+ transform ||= block
22
+ runner = Runner.new(model_class: model_class, theme: theme, batch_size: batch_size, release: release)
23
+ runner.import_from_file(file_path, theme: theme, transform: transform)
24
+ runner
25
+ end
26
+ end
27
+ end