Skip to content

Commit b687e99

Browse files
committed
uk polygons with backfill of uk_geolocation
1 parent c743221 commit b687e99

63 files changed

Lines changed: 7544 additions & 865 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.simplecov

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ if ENV.fetch("COVERAGE", 0).to_i.positive?
5454
# .25% lower (branch) and .1% lower (line) than the test run for now
5555
#
5656
# possibly the tests are stable now?
57-
# SD 16/01/26 branch coverage still appears to be ~ .1% unstable
58-
# 97.48% (12352 / 12673) -> 322 86.37% (2776 / 3214) -> 185 + 225 = 410
59-
minimum_coverage line: 97.44, branch: 87.12
57+
# SD 27/01/26 branch coverage still appears to be ~ .1% unstable
58+
# 97.47% (12400 / 12722) -> 322 87.11% (2785 / 3197) -> 197 + 215 = 412
59+
minimum_coverage line: 97.45, branch: 87.1
6060
end
6161
end

app/helpers/maps_helper.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
module MapsHelper
22
def vacancy_organisations_map_markers(vacancy)
3-
vacancy.organisations.map do |organisation|
4-
{
5-
id: vacancy.id,
6-
parent_id: organisation.id,
7-
geopoint: RGeo::GeoJSON.encode(organisation.geopoint)&.to_json,
8-
}
3+
vacancy.organisations.filter_map do |organisation|
4+
if organisation.uk_geopoint?
5+
{
6+
id: vacancy.id,
7+
parent_id: organisation.id,
8+
geopoint: RGeo::GeoJSON.encode(GeoFactories.convert_sr27700_to_wgs84(organisation.uk_geopoint)).to_json,
9+
}
10+
end
911
end
1012
end
1113

1214
def organisation_map_marker(organisation)
1315
[
1416
{
1517
parent_id: organisation.id,
16-
geopoint: RGeo::GeoJSON.encode(organisation.geopoint)&.to_json,
18+
geopoint: RGeo::GeoJSON.encode(GeoFactories.convert_sr27700_to_wgs84(organisation.uk_geopoint)).to_json,
1719
},
1820
]
1921
end
2022

2123
def organisation_map_can_be_displayed?(vacancy)
2224
return true unless vacancy.central_office?
2325

24-
vacancy.organisation.geopoint.present?
26+
# :nocov:
27+
vacancy.organisation.uk_geopoint.present?
28+
# :nocov:
2529
end
2630
end

app/models/job_preferences/location.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,27 @@ class Location < ApplicationRecord
66

77
self.table_name = "job_preferences_locations"
88
belongs_to :job_preferences
9+
self.ignored_columns += %i[area]
910

1011
validates :name, presence: true
1112
validates :radius, presence: true
1213
validates :uk_area, presence: true
1314

1415
before_validation :set_area
1516

16-
scope :containing, ->(point) { where("ST_Within(ST_GeomFromEWKT(?), area::geometry)", "SRID=4326;#{point.as_text}") }
17+
scope :containing, ->(point) { where(arel_table[:uk_area].st_contains(point)) }
1718

1819
private
1920

2021
def set_area
2122
if LocationPolygon.contain?(name)
22-
polygon = LocationPolygon.buffered(radius).with_name(name)
23-
self.area = polygon.area
24-
self.uk_area = polygon.uk_area
23+
# :nocov:
24+
self.uk_area = LocationPolygon.buffered(radius).with_name(name).uk_area
25+
# :nocov:
2526
else
2627
lat, long = Geocoding.new(name).coordinates.map(&:to_s)
2728
radius_meters = convert_miles_to_metres(Search::RadiusBuilder.new(name, radius).radius)
28-
geopoint = GeoFactories::FACTORY_4326.point(long, lat)
29-
self.area = geopoint.buffer(radius_meters)
30-
self.uk_area = GeoFactories.convert_wgs84_to_sr27700(geopoint).buffer(radius_meters)
29+
self.uk_area = GeoFactories.convert_wgs84_to_sr27700(GeoFactories::FACTORY_4326.point(long, lat).buffer(radius_meters))
3130
end
3231
end
3332
end

app/models/location_polygon.rb

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
class LocationPolygon < ApplicationRecord
22
extend DistanceHelper
33

4-
# British National Grid SRID (EPSG:27700) is a projected coordinate system used for mapping in Great Britain.
5-
# It provides coordinates in meters, which is useful for distance calculations, which we need
6-
# for radius-based searches.
7-
# It is significantly more accurate for distance calculations in Great Britain that EPSG:3857 (Web Mercator).
8-
# EPSG:3857 distort distances and areas, especially as you move away from the equator. What would cause a multiplier
9-
# between 1.5x and 1.7x for radius/buffer distances in our case to get the matches we would expect.
10-
BRITISH_NATIONAL_GRID_SRID = 27700 # rubocop:disable Style/NumericLiterals
11-
124
validates :name, presence: true
135

146
# Scope that expands any polygons returned by subsequent scopes by the provided radius
157
# by overriding the `area` attribute with a buffered version of itself
16-
scope :buffered, ->(radius_in_miles) { select("*, ST_Buffer(area, #{convert_miles_to_metres(radius_in_miles || 0)}) AS area") }
8+
scope :buffered, ->(radius_in_miles) { select("*, ST_Buffer(uk_area, #{convert_miles_to_metres(radius_in_miles || 0)}) AS uk_area") }
9+
10+
self.ignored_columns += %i[area centroid]
1711

1812
def self.with_name(location)
1913
find_by(name: mapped_name(location))
@@ -29,48 +23,21 @@ def self.mapped_name(location)
2923

3024
def self.find_valid_for_location(location)
3125
polygon = with_name(location)
32-
if polygon.present? && polygon.area.invalid_reason.nil?
26+
if polygon.present? && polygon.uk_area.invalid_reason.nil?
3327
polygon
3428
end
3529
rescue RGeo::Error::InvalidGeometry
3630
nil
3731
end
3832

3933
# Buffers the polygon's area by the given radius in metres and returns the resulting expanded area in geometry.
40-
# Transformations are needed since the original area is a geographic type.
41-
#
4234
# The "where & pick" approach is more efficient to retrieve a single computed value from the DB than loading the whole object.
43-
#
44-
# Why buffering in British National Grid SRID (27700) to then store it as SRID 4326?
45-
# Buffering is best done in a projected coordinate system as it buffers in metres instead of degrees).
46-
# After buffering, we transform to SRID: 4326 (lat/lon data). As we're doing spatial queries on this data, 4326 is more appropriate.
4735
def buffered_geometry_area(radius_in_metres)
48-
wkb = self.class.where(id: id).pick(
49-
Arel.sql(
50-
"ST_AsBinary(
51-
ST_Transform(
52-
ST_Buffer(
53-
ST_Transform(area::geometry, #{BRITISH_NATIONAL_GRID_SRID}),
54-
#{radius_in_metres}
55-
),
56-
4326)
57-
)",
58-
),
59-
)
60-
return nil unless wkb
61-
62-
# Has to use this or the returned geometry gets a SRID: 0 even when set in the DB as 4326
63-
RGeo::Cartesian.factory(srid: 4326).parse_wkb(wkb)
64-
rescue RGeo::Error::InvalidGeometry
65-
nil
66-
end
67-
68-
# Buffers the polygon's area by the given radius in metres and returns the resulting expanded area in geometry.
69-
# The "where & pick" approach is more efficient to retrieve a single computed value from the DB than loading the whole object.
70-
def buffered_geometry_uk_area(radius_in_metres)
7136
wkb = self.class.where(id: id)
72-
.pick(Arel.sql("ST_AsBinary(ST_Buffer(uk_area::geometry,#{radius_in_metres}))"))
37+
.pick(Arel.sql("ST_AsBinary(ST_Buffer(uk_area::geometry,#{radius_in_metres}))"))
7338
# Has to use this or the returned geometry gets a SRID: 0 even when set in the DB as 27700
7439
GeoFactories::FACTORY_27700.parse_wkb(wkb) if wkb
40+
rescue RGeo::Error::InvalidGeometry
41+
nil
7542
end
7643
end

app/models/organisation.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class Organisation < ApplicationRecord
2424
has_many :jobseeker_profile_exclusions, class_name: "JobseekerProfileExcludedOrganisation"
2525
has_many :hidden_jobseeker_profiles, through: :jobseeker_profile_exclusions, source: :jobseeker_profile
2626

27+
self.ignored_columns += %i[geopoint]
28+
2729
scope :not_closed, -> { where.not(establishment_status: "Closed") }
2830
scope :schools, -> { where(type: "School") }
2931
scope :school_groups, -> { where(type: "SchoolGroup") }

app/models/subscription.rb

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class Subscription < ApplicationRecord
2020
# support_job_roles used to be called teaching_support_job_roles and non_teaching_support_job_roles in the past, and there are still active subscriptions with this name
2121
JOB_ROLE_ALIASES = %i[teaching_job_roles support_job_roles teaching_support_job_roles non_teaching_support_job_roles].freeze
2222

23+
self.ignored_columns += %i[area geopoint]
24+
2325
def self.encryptor(serializer: :json_allow_marshal)
2426
key_generator_secret = SUBSCRIPTION_KEY_GENERATOR_SECRET
2527
key_generator_salt = SUBSCRIPTION_KEY_GENERATOR_SALT
@@ -96,25 +98,16 @@ def set_location_data!
9698

9799
# A subscription with location area has a polygon area seat buffered by radius, no geopoint.
98100
def set_location_from_polygon(polygon, radius)
99-
# Cast polygon area from geography to geometry and buffer by radius before storing
100-
self.area = polygon.buffered_geometry_area(self.class.convert_miles_to_metres(radius))
101-
self.geopoint = nil
102-
103-
self.uk_area = polygon.buffered_geometry_uk_area(self.class.convert_miles_to_metres(radius))
101+
self.uk_area = polygon.buffered_geometry_area(self.class.convert_miles_to_metres(radius))
104102
self.uk_geopoint = nil
105-
106103
self.radius_in_metres = self.class.convert_miles_to_metres(radius)
107104
end
108105

109106
# A subscription with location coordinates has a geopoint, no area.
110107
def set_location_from_coordinates(coordinates, radius)
111-
self.geopoint = RGeo::Cartesian.factory(srid: 4326).point(coordinates.second, coordinates.first)
112-
self.area = nil
113-
114108
geopoint = GeoFactories::FACTORY_4326.point(coordinates.second, coordinates.first)
115109
self.uk_geopoint = GeoFactories.convert_wgs84_to_sr27700 geopoint
116110
self.uk_area = nil
117-
118111
self.radius_in_metres = self.class.convert_miles_to_metres(radius)
119112
end
120113
end

app/models/vacancy.rb

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ class Vacancy < ApplicationRecord
157157

158158
after_save :update_conversation_searchable_content, if: -> { saved_change_to_job_title? }
159159

160+
self.ignored_columns += %i[geolocation]
161+
160162
EQUAL_OPPORTUNITIES_PUBLICATION_THRESHOLD = 5
161163
EXPIRY_TIME_OPTIONS = %w[8:00 9:00 12:00 15:00 23:59].freeze
162164

@@ -268,11 +270,11 @@ def salary_types
268270
end
269271

270272
def distance_in_miles_to(search_coordinates)
271-
if geolocation.is_a? RGeo::Geographic::SphericalMultiPointImpl
273+
if uk_geolocation.is_a? RGeo::Cartesian::MultiPointImpl
272274
# if there are multiple geolocations then return the distance to the nearest one to the given search location
273-
geolocation.map { |geolocation| calculate_distance(search_coordinates, geolocation) }.min
275+
uk_geolocation.map { |geolocation| self.class.calculate_distance(search_coordinates, geolocation) }.min
274276
else
275-
calculate_distance(search_coordinates, geolocation)
277+
self.class.calculate_distance(search_coordinates, uk_geolocation)
276278
end
277279
end
278280

@@ -303,8 +305,12 @@ def update_conversation_searchable_content
303305
.find_each(&:update_searchable_content)
304306
end
305307

306-
def calculate_distance(search_coordinates, geolocation)
307-
Geocoder::Calculations.distance_between(search_coordinates, [geolocation.latitude, geolocation.longitude])
308+
class << self
309+
def calculate_distance(search_coordinates, geolocation)
310+
search_location = GeoFactories::FACTORY_4326.point(search_coordinates.second, search_coordinates.first)
311+
search_point = GeoFactories.convert_wgs84_to_sr27700 search_location
312+
search_point.distance(geolocation) / DistanceHelper::METRES_PER_MILE
313+
end
308314
end
309315

310316
def slug_candidates
@@ -317,18 +323,12 @@ def slug_candidates
317323
# In the former case, it gets an argument, which we don't need and thus ignore
318324
#
319325
def refresh_geolocation(_school_added_or_removed = nil)
320-
self.geolocation = if organisations.one?
321-
organisation.geopoint
322-
else
323-
points = organisations.filter_map(&:geopoint)
324-
points.presence && points.first.factory.multi_point(points)
325-
end
326-
# self.uk_geolocation = if organisations.one?
327-
# organisation.uk_geopoint
328-
# else
329-
# uk_points = organisations.filter_map(&:uk_geopoint)
330-
# uk_points.presence && uk_points.first.factory.multi_point(uk_points)
331-
# end
326+
self.uk_geolocation = if organisations.one?
327+
organisation.uk_geopoint
328+
else
329+
uk_points = organisations.filter_map(&:uk_geopoint)
330+
uk_points.presence && uk_points.first.factory.multi_point(uk_points)
331+
end
332332
end
333333
end
334334
# rubocop:enable Metrics/ClassLength

app/queries/location_query.rb

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def nationwide_location?(query)
3232
def handle_polygon_location(field_name, polygon, radius, sort_by_distance)
3333
@scope = scope.joins("
3434
INNER JOIN location_polygons
35-
ON ST_DWithin(#{field_name}, location_polygons.area, #{radius}, false)
35+
ON ST_DWithin(#{field_name}, location_polygons.uk_area, #{radius})
3636
").where("location_polygons.id = ?", polygon.id)
3737

3838
sort_by_polygon_distance(field_name) if sort_by_distance
@@ -47,23 +47,24 @@ def handle_coordinates(field_name, query, radius, sort_by_distance)
4747
# suitable error instead. Refactor later!
4848
return scope.none if coordinates == [0, 0]
4949

50-
point = "POINT(#{coordinates.second} #{coordinates.first})"
51-
@scope = scope.where("ST_DWithin(#{field_name}, ?, ?, false)", point, radius)
50+
earth_point = GeoFactories::FACTORY_4326.point(coordinates.second, coordinates.first)
51+
point = GeoFactories.convert_wgs84_to_sr27700 earth_point
52+
@scope = scope.where("ST_DWithin(#{field_name}, ?, ?)", "SRID=#{point.srid};#{point}", radius)
5253

5354
sort_by_coordinates_distance(field_name, point) if sort_by_distance
5455

5556
scope
5657
end
5758

5859
def sort_by_polygon_distance(field_name)
59-
@scope = scope.select("vacancies.*, ST_Distance(#{field_name}, location_polygons.centroid, false) AS distance")
60+
@scope = scope.select("vacancies.*, ST_Distance(#{field_name}, location_polygons.uk_centroid) AS distance")
6061
# why not using 'distance' alias? is not defined when calling this query with a 'pluck'
61-
.order(Arel.sql("ST_Distance(#{field_name}, location_polygons.centroid, false)"))
62+
.order(Arel.sql("ST_Distance(#{field_name}, location_polygons.uk_centroid)"))
6263
end
6364

6465
def sort_by_coordinates_distance(field_name, point)
65-
@scope = scope.select("vacancies.*, ST_Distance(#{field_name}, '#{point}', false) AS distance")
66+
@scope = scope.select("vacancies.*, ST_Distance(#{field_name}, 'SRID=#{point.srid};#{point}') AS distance")
6667
# why not using 'distance' alias? is not defined when calling this query with a 'pluck'
67-
.order(Arel.sql("ST_Distance(#{field_name}, '#{point}', false)"))
68+
.order(Arel.sql("ST_Distance(#{field_name}, 'SRID=#{point.srid};#{point}')"))
6869
end
6970
end

app/queries/organisation_location_query.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ def initialize(scope = Organisation.all)
44
end
55

66
def call(...)
7-
super("organisations.geopoint", ...)
7+
super("organisations.uk_geopoint", ...)
88
end
99
end

app/queries/subscription_vacancies_matching_query.rb

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
class SubscriptionVacanciesMatchingQuery
22
attr_reader :scope
33

4-
# British National Grid SRID (EPSG:27700) is a projected coordinate system used for mapping in Great Britain.
5-
# It provides coordinates in meters, which is useful for distance calculations, which we need
6-
# for radius-based searches.
7-
# It is significantly more accurate for distance calculations in Great Britain that EPSG:3857 (Web Mercator).
8-
# EPSG:3857 distort distances and areas, especially as you move away from the equator. What would cause a multiplier
9-
# between 1.5x and 1.7x for radius/buffer distances in our case to get the matches we would expect.
10-
BRITISH_NATIONAL_GRID_SRID = 27700 # rubocop:disable Style/NumericLiterals
11-
124
# Builds the query to find the IDs for the vacancies matching the subscription criteria.
135
# The subquery is needed to be able to combine our requirements into a valid single SQL statement:
146
# - retrieve unique DISTINCT ON vacancy ids: As the location filter may return the same vacancy multiple times (vacancy for multiple orgs).
@@ -177,9 +169,9 @@ def location_filter(scope, subscription_location, subscription)
177169
# 'area_before_type_cast' and 'geopoint_before_type_cast' are used to avoid casting the fields into RGeo objects.
178170
# This reduces memory usage and speeds up the query. As we don't need to use the actual objects in Ruby code,
179171
# just need to know if they are present in DB.
180-
if subscription.area_before_type_cast.present?
172+
if subscription.uk_area_before_type_cast.present?
181173
location_by_area_filter(scope, subscription)
182-
elsif subscription.geopoint_before_type_cast.present? && subscription.radius_in_metres.present?
174+
elsif subscription.uk_geopoint_before_type_cast.present? && subscription.radius_in_metres.present?
183175
location_by_geopoint_filter(scope, subscription)
184176
else
185177
scope.none # Invalid location filter (having no area or geopoint) returns no matches
@@ -197,9 +189,11 @@ def location_filter(scope, subscription_location, subscription)
197189
# Including 'publish_on' in the distinct: The provided scope may be ordered by publish on date. When using distinct
198190
# with ordering, Postgres requires all selected columns to be included in the distinct clause or will fail during execution.
199191
def location_by_area_filter(scope, subscription)
192+
subscriptions = Subscription.arel_table
193+
organisations = Organisation.arel_table
200194
scope.joins("INNER JOIN subscriptions ON subscriptions.id = '#{subscription.id}'")
201195
.joins(:organisations)
202-
.where("ST_Contains(subscriptions.area, organisations.geopoint::geometry)")
196+
.where(subscriptions[:uk_area].st_contains(organisations[:uk_geopoint]))
203197
end
204198

205199
# Filter vacancies where their organisations' geopoints fall within the subscription's radius from the subscription's
@@ -214,8 +208,8 @@ def location_by_area_filter(scope, subscription)
214208
def location_by_geopoint_filter(scope, subscription)
215209
scope.joins("INNER JOIN subscriptions ON subscriptions.id = '#{subscription.id}'")
216210
.joins(:organisations)
217-
.where("ST_DWithin(ST_Transform(organisations.geopoint::geometry, #{BRITISH_NATIONAL_GRID_SRID}),
218-
ST_Transform(subscriptions.geopoint, #{BRITISH_NATIONAL_GRID_SRID}),
211+
.where("ST_DWithin(organisations.uk_geopoint::geometry,
212+
subscriptions.uk_geopoint,
219213
subscriptions.radius_in_metres)")
220214
end
221215
end

0 commit comments

Comments
 (0)