Skip to content

Commit 5a7c1c0

Browse files
committed
Add near Search Param for Location Resource
Signed-off-by: Jonas Wagner <jwagner@knoppiks.de>
1 parent eda1c40 commit 5a7c1c0

12 files changed

Lines changed: 472 additions & 3 deletions

File tree

docs/api/interaction/search-type.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ Search for `Resource.meta.profile` is supported using the `_profile` search para
4040

4141
When searching for date/time with a search parameter value without timezone like `2024` or `2024-02-16`, Blaze calculates the range of the search parameter values based on [UTC][2]. That means that a resource with a date/time value staring at `2024-01-01T00:00:00+01:00` will be not found by a search with `2024`. Please comment on [issue #1498](https://github.com/samply/blaze/issues/1498) if you like to have this situation improved.
4242

43+
## Geospatial Search
44+
45+
When searching for Locations using the `near` modifier, Blaze uses the [Haversine formula](https://en.wikipedia.org/wiki/Haversine_formula) to calculate the distance between the search parameter value and the resource's location, which simplifies by assuming a spherical earth and has an error of less than approximately 0.5%.
46+
4347
## Sorting
4448

4549
The special search parameter `_sort` supports the values `_id`, `_lastUpdated` and `-_lastUpdated`.

modules/db/src/blaze/db/impl/search_param.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
[blaze.db.impl.search-param.date]
1414
[blaze.db.impl.search-param.has]
1515
[blaze.db.impl.search-param.list]
16+
[blaze.db.impl.search-param.near]
1617
[blaze.db.impl.search-param.number]
1718
[blaze.db.impl.search-param.quantity]
1819
[blaze.db.impl.search-param.string]
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
(ns blaze.db.impl.search-param.near
2+
(:require
3+
[blaze.anomaly :as ba :refer [when-ok]]
4+
[blaze.async.comp :as ac]
5+
[blaze.coll.core :as coll]
6+
[blaze.db.api :as d]
7+
[blaze.db.impl.index.index-handle :as ih]
8+
[blaze.db.impl.index.single-version-id :as svi]
9+
[blaze.db.impl.protocols :as p]
10+
[blaze.db.impl.search-param.near.geo :as spng]
11+
[blaze.db.impl.search-param.special :as special]
12+
[blaze.db.impl.search-param.util :as u]
13+
[blaze.fhir.spec.type.system :as system]
14+
[clojure.string :as str]
15+
[cognitect.anomalies :as anom]))
16+
17+
(set! *warn-on-reflection* true)
18+
19+
(defn- matches? [batch-db resource-handle values]
20+
(-> (d/pull batch-db resource-handle)
21+
(ac/then-apply
22+
(fn [{:keys [position]}]
23+
(some (fn [{:keys [distance] :as value}]
24+
(spng/near? position value distance)) values)))
25+
(ac/join)))
26+
27+
(defn- unsupported-unit-msg [unit code]
28+
(format "Unsupported unit `%s` in search parameter `%s`. Supported are 'km', 'm'." unit code))
29+
30+
(defn- missing-param-msg [arg code]
31+
(format "Missing argument `%s` in search parameter `%s`." arg code))
32+
33+
(defn- param-parsing-err-msg [arg code]
34+
(format "Error parsing argument `%s` in search parameter `%s`." arg code))
35+
36+
(defn- update-msg [msg]
37+
(fn [anomaly]
38+
(update anomaly ::anom/message #(str msg " " %))))
39+
40+
(defn- parse-dec-arg [coord name code]
41+
(when-ok [_ (when (str/blank? coord)
42+
(ba/incorrect (missing-param-msg name code)))]
43+
(-> (system/parse-decimal coord)
44+
(ba/exceptionally (update-msg (param-parsing-err-msg name code))))))
45+
46+
(defn- distance-in-meters [dist unit code]
47+
(cond
48+
(str/blank? dist)
49+
1000M
50+
51+
(or (nil? unit) (= unit "km"))
52+
(when-ok [dist (parse-dec-arg dist "distance" code)]
53+
(* dist 1000))
54+
55+
(= unit "m")
56+
(parse-dec-arg dist "distance" code)
57+
58+
:else
59+
(ba/incorrect (unsupported-unit-msg unit code))))
60+
61+
(defrecord SearchParamNear [index name type code]
62+
p/SearchParam
63+
(-validate-modifier [_ modifier]
64+
(some->> modifier (u/unknown-modifier-anom code)))
65+
66+
(-compile-value [_ _ value]
67+
(let [[lat long dist unit] (str/split value #"\|" 4)]
68+
(when-ok [parsed-lat (parse-dec-arg lat "latitude" code)
69+
parsed-long (parse-dec-arg long "longitude" code)
70+
meters (distance-in-meters dist unit code)]
71+
{:latitude parsed-lat
72+
:longitude parsed-long
73+
:distance meters})))
74+
75+
(-estimated-scan-size [_ _ _ _ _]
76+
(ba/unsupported))
77+
78+
(-supports-ordered-index-handles [_ _ _ _ _]
79+
true)
80+
81+
(-ordered-index-handles [search-param batch-db tid modifier compiled-values]
82+
(if (= 1 (count compiled-values))
83+
(p/-index-handles search-param batch-db tid modifier (first compiled-values))
84+
(let [index-handles #(p/-index-handles search-param batch-db tid modifier %)]
85+
(u/union-index-handles (map index-handles compiled-values)))))
86+
87+
(-ordered-index-handles [search-param batch-db tid modifier compiled-values start-id]
88+
(if (= 1 (count compiled-values))
89+
(p/-index-handles search-param batch-db tid modifier (first compiled-values) start-id)
90+
(let [index-handles #(p/-index-handles search-param batch-db tid modifier % start-id)]
91+
(u/union-index-handles (map index-handles compiled-values)))))
92+
93+
(-index-handles [search-param batch-db tid modifier compiled-value]
94+
(coll/eduction
95+
(comp (p/-matcher search-param batch-db modifier [compiled-value])
96+
(map ih/from-resource-handle))
97+
(d/type-list batch-db tid)))
98+
99+
(-index-handles [search-param batch-db tid modifier compiled-value start-id]
100+
(coll/eduction
101+
(comp (p/-matcher search-param batch-db modifier [compiled-value])
102+
(map ih/from-resource-handle))
103+
(d/type-list batch-db tid start-id)))
104+
105+
(-supports-ordered-compartment-index-handles [_ _]
106+
false)
107+
108+
(-ordered-compartment-index-handles [_ _ _ _ _]
109+
(ba/unsupported))
110+
111+
(-ordered-compartment-index-handles [_ _ _ _ _ _]
112+
(ba/unsupported))
113+
114+
(-matcher [_ batch-db _ compiled-values]
115+
(filter #(matches? batch-db % compiled-values)))
116+
117+
(-single-version-id-matcher [search-param batch-db tid modifier compiled-values]
118+
(comp (map ih/from-single-version-id)
119+
(u/resource-handle-xf batch-db tid)
120+
(p/-matcher search-param batch-db modifier compiled-values)
121+
(map svi/from-resource-handle)))
122+
123+
(-second-pass-filter [_ _ _])
124+
125+
(-index-values [_ _ _]
126+
[]))
127+
128+
(defmethod special/special-search-param "near"
129+
[index _]
130+
(->SearchParamNear index "near" "special" "near"))
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
(ns blaze.db.impl.search-param.near.geo)
2+
3+
(set! *warn-on-reflection* true)
4+
5+
(def ^:private ^:const earth-radius
6+
"Earths mean radius in meters, see https://en.wikipedia.org/wiki/Earth_radius."
7+
6371008.7714)
8+
9+
(defn- sin2
10+
"Sine of `angle` squared."
11+
[angle]
12+
(* (Math/sin angle) (Math/sin angle)))
13+
14+
(defn haversine-distance
15+
"Calculate the distance between two geographic coordinates (`location-1` and
16+
`location-2`) using the Haversine formula, which simplifies by assuming a
17+
spherical earth (error ≤ 0.5%). Returns distance in meters. See
18+
https://en.wikipedia.org/wiki/Haversine_formula."
19+
{:arglists '([location-1 location-2])}
20+
[{lat-1 :latitude lon-1 :longitude} {lat-2 :latitude lon-2 :longitude}]
21+
(let [delta-lat (Math/toRadians (- lat-2 lat-1))
22+
delta-long (Math/toRadians (- lon-2 lon-1))
23+
alpha (+ (sin2 (/ delta-lat 2))
24+
(* (Math/cos (Math/toRadians lat-1))
25+
(Math/cos (Math/toRadians lat-2))
26+
(sin2 (/ delta-long 2))))]
27+
(* earth-radius (Math/asin (Math/sqrt alpha)) 2)))
28+
29+
(defn near?
30+
"Check if `location-1` and `location-2` are within `distance` meters of each
31+
other using haversine-distance."
32+
[location-1 location-2 distance]
33+
(let [actual-dist (haversine-distance location-1 location-2)]
34+
(<= actual-dist distance)))

modules/db/src/blaze/db/search_param_registry.clj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,18 @@
221221
{:type "special"
222222
:name "_has"})
223223

224+
(def ^:private near-search-param
225+
{:type "special"
226+
:name "near"})
227+
224228
(defn- add-special
225229
"Add special search params to `index`.
226230
227231
See: https://www.hl7.org/fhir/search.html#special"
228232
[index]
229233
(-> (assoc-in index ["Resource" "_list"] (sc/search-param nil list-search-param))
230-
(assoc-in ["Resource" "_has"] (sc/search-param index has-search-param))))
234+
(assoc-in ["Resource" "_has"] (sc/search-param index has-search-param))
235+
(assoc-in ["Location" "near"] (sc/search-param index near-search-param))))
231236

232237
(defn- build-url-index* [index filter entries]
233238
(transduce

modules/db/test/blaze/db/api_test.clj

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7461,7 +7461,35 @@
74617461
(given (into [] xform (d/type-list db "Observation"))
74627462
count := 1
74637463
[0 :fhir/type] := :fhir/Observation
7464-
[0 :id] := "0")))))))
7464+
[0 :id] := "0"))))))
7465+
7466+
(testing "near search param"
7467+
(with-system-data [{:blaze.db/keys [node]} config]
7468+
[[[:put {:fhir/type :fhir/Location :id "0"
7469+
:position
7470+
{:fhir/type :fhir.Location/position ; Berlin
7471+
:latitude #fhir/decimal 52.5200M :longitude #fhir/decimal 13.4050M}}]
7472+
[:put {:fhir/type :fhir/Location :id "1"
7473+
:position
7474+
{:fhir/type :fhir.Location/position ; Cologne
7475+
:latitude #fhir/decimal 50.9334M :longitude #fhir/decimal 6.9619M}}]
7476+
[:put {:fhir/type :fhir/Location :id "2"
7477+
:position
7478+
{:fhir/type :fhir.Location/position ; Jakarta
7479+
:latitude #fhir/decimal -6.2M :longitude #fhir/decimal 106.8167M}}]]]
7480+
7481+
(with-open-db [db node]
7482+
(doseq [target [node db]
7483+
[_ query id] [[:Berlin "52.5201|13.4051|15|m" "0"]
7484+
[:Leipzig "51.3397|12.3731|150|km" "0"]
7485+
[:Paris "48.8566|2.3522|403|km" "1"]
7486+
[:Perth "-31.953512|115.857048|3014|km" "2"]]]
7487+
(let [matcher (d/compile-type-matcher target "Location" [["near" query]])
7488+
xform (d/matcher-transducer db matcher)]
7489+
(given (into [] xform (d/type-list db "Location"))
7490+
count := 1
7491+
[0 :fhir/type] := :fhir/Location
7492+
[0 :id] := id)))))))
74657493

74667494
(deftest compile-system-matcher-test
74677495
(with-system-data [{:blaze.db/keys [node]} config]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
(ns blaze.db.impl.search-param.near.geo-spec
2+
(:require
3+
[clojure.spec.alpha :as s]))
4+
5+
(s/def ::coordinate
6+
number?)
7+
8+
(s/def ::latitude
9+
::coordinate)
10+
11+
(s/def ::longitude
12+
::coordinate)
13+
14+
(s/def ::location
15+
(s/keys :req-un [::latitude ::longitude]))
16+
17+
(s/fdef haversine-distance
18+
:args (s/cat :loc-1 ::location :loc-2 ::location)
19+
:ret number?)
20+
21+
(s/fdef near?
22+
:args (s/cat :loc-1 ::location :loc-2 ::location :dist number?)
23+
:ret boolean?)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
(ns blaze.db.impl.search-param.near.geo-test
2+
(:require
3+
[blaze.db.impl.search-param.near.geo :as geo]
4+
[blaze.db.impl.search-param.near.geo-spec]
5+
[clojure.test :refer [deftest is testing]]))
6+
7+
;; Test data
8+
(def ^:private london
9+
{:latitude 51.5074 :longitude -0.1278})
10+
11+
(def ^:private paris
12+
{:latitude 48.8566 :longitude 2.3522})
13+
14+
(def ^:private jakarta
15+
{:latitude -6.2M :longitude 106.8167M})
16+
17+
(def ^:private perth
18+
{:latitude -31.953512 :longitude 115.857048})
19+
20+
(deftest haversine-distance-test
21+
(testing "distance between identical locations"
22+
(is (= 0.0 (geo/haversine-distance london london))))
23+
24+
(testing "distance between London and Paris"
25+
;; ~ 343km
26+
(let [dist (geo/haversine-distance london paris)]
27+
(is (<= dist 344000.0))
28+
(is (>= dist 343000.0))))
29+
30+
(testing "distance between Jakarta and Perth"
31+
;; ~ 3013km
32+
(let [dist (geo/haversine-distance jakarta perth)]
33+
(is (<= dist 3014000.0))
34+
(is (>= dist 3013000.0))))
35+
36+
(testing "distance between two points on equator 1 degree apart"
37+
;; ~ 111.195km
38+
(let [dist (geo/haversine-distance
39+
{:latitude 0.0 :longitude 0.0}
40+
{:latitude 0.0 :longitude 1.0})]
41+
(is (<= dist 111200.0))
42+
(is (>= dist 111190.0))))
43+
44+
(testing "distance when crossing international date line (also 1 degree)"
45+
;; ~ 111.195km
46+
(let [dist (geo/haversine-distance
47+
{:latitude 0.0 :longitude 179.5}
48+
{:latitude 0.0 :longitude -179.5})]
49+
(is (<= dist 111200.0))
50+
(is (>= dist 111190.0)))))
51+
52+
(deftest near?-test
53+
(testing "locations are within distance"
54+
(is (geo/near? london paris 344000.0))
55+
(is (geo/near? london paris 350000.0))
56+
(is (geo/near? london paris 400000.0)))
57+
58+
(testing "locations are not within distance"
59+
(is (not (geo/near? london paris 340000.0)))
60+
(is (not (geo/near? london paris 300000.0))))
61+
62+
(testing "locations are roughly at distance"
63+
(is (geo/near? london paris 343560.0)))
64+
65+
(testing "identical locations with zero distance"
66+
(is (geo/near? london london 0.0)))
67+
68+
(testing "identical locations with non-zero distance"
69+
(is (geo/near? london london 100.0))))

0 commit comments

Comments
 (0)