tg_geometry 0.2.0 → 0.3.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +107 -14
  4. data/benchmark/_support.rb +450 -5
  5. data/benchmark/batch_packed_vs_loop.rb +38 -6
  6. data/benchmark/ewkb_roundtrip.rb +38 -0
  7. data/benchmark/falcon_concurrency.rb +17 -10
  8. data/benchmark/feature_source.rb +49 -23
  9. data/benchmark/flat_vs_rtree.rb +53 -11
  10. data/benchmark/geom_query.rb +43 -0
  11. data/benchmark/gvl_threshold.rb +10 -11
  12. data/benchmark/nearest_segment.rb +24 -0
  13. data/benchmark/objectspace_memsize.rb +14 -1
  14. data/benchmark/parse_throughput.rb +13 -10
  15. data/benchmark/rss_stability.rb +35 -24
  16. data/docs/GEOMETRY_QUERIES.md +23 -0
  17. data/docs/LIMITATIONS.md +12 -10
  18. data/docs/NEAREST_SEGMENT.md +17 -0
  19. data/docs/SRID_AND_EWKB.md +23 -0
  20. data/ext/tg_geometry/tg_geometry_ext.c +1234 -27
  21. data/lib/tg/geometry/active_record_source.rb +16 -34
  22. data/lib/tg/geometry/active_record_type.rb +61 -0
  23. data/lib/tg/geometry/registry.rb +17 -68
  24. data/lib/tg/geometry/version.rb +1 -1
  25. data/lib/tg/geometry.rb +85 -0
  26. data/spec/active_record_type_spec.rb +45 -0
  27. data/spec/constructors_spec.rb +104 -0
  28. data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
  29. data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
  30. data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
  31. data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
  32. data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
  33. data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
  34. data/spec/fixtures/postgis/README.md +16 -0
  35. data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
  36. data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
  37. data/spec/fixtures/postgis/point_4326.ewkb +0 -0
  38. data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
  39. data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
  40. data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
  41. data/spec/index_geom_query_spec.rb +68 -0
  42. data/spec/keyword_validation_spec.rb +31 -0
  43. data/spec/nearest_segment_spec.rb +62 -0
  44. data/spec/postgis_fixtures_spec.rb +68 -0
  45. data/spec/srid_spec.rb +43 -0
  46. data/spec/to_ewkb_spec.rb +37 -0
  47. metadata +50 -2
@@ -3,25 +3,57 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("batch_packed_vs_loop")
6
- iterations = TGGeometryBench.iterations(500)
6
+ points_per_batch = TGGeometryBench.env_integer("TGEOMETRY_BENCH_BATCH_POINTS", 1_000, min: 1)
7
7
 
8
8
  %i[compact long_thin overlapping].each do |kind|
9
9
  TGGeometryBench.sizes.each do |size|
10
10
  entries = TGGeometryBench.entries_for(kind, size)
11
- points = Array.new(1_000) { |i| TGGeometryBench.points_for(kind)[i % TGGeometryBench.points_for(kind).length] }
11
+ points = TGGeometryBench.repeated_points(kind, points_per_batch)
12
12
  packed = TGGeometryBench.packed_points(points)
13
13
 
14
14
  %i[flat rtree].each do |strategy|
15
15
  index = TGGeometryBench.build_index(entries, strategy: strategy)
16
16
 
17
- scalar_time = Benchmark.realtime do
18
- iterations.times { points.map { |lon, lat| index.find_covering(lon, lat) } }
17
+ scalar = TGGeometryBench.measure_counted(
18
+ initial_iterations: TGGeometryBench.initial_iterations(50),
19
+ operations_per_iteration: points.length
20
+ ) do |iterations|
21
+ iterations.times do
22
+ points.each { |lon, lat| index.find_covering(lon, lat) }
23
+ end
19
24
  end
20
- batch_time = Benchmark.realtime do
25
+
26
+ batch = TGGeometryBench.measure_counted(
27
+ initial_iterations: TGGeometryBench.initial_iterations(50),
28
+ operations_per_iteration: points.length
29
+ ) do |iterations|
21
30
  iterations.times { index.covering_ids_batch_packed(packed) }
22
31
  end
23
32
 
24
- puts "kind=#{kind} n=#{size} strategy=#{strategy} points=#{points.length} scalar_sec=%.6f batch_sec=%.6f scalar_batches_per_sec=%.2f batch_batches_per_sec=%.2f" % [scalar_time, batch_time, iterations / scalar_time, iterations / batch_time]
33
+ TGGeometryBench.report(
34
+ "batch_packed_vs_loop",
35
+ {
36
+ kind: kind,
37
+ n: size,
38
+ strategy: strategy,
39
+ mode: :scalar_loop,
40
+ points_per_batch: points.length,
41
+ batches_per_sec: scalar[:ops_per_sec] / points.length
42
+ },
43
+ stats: scalar
44
+ )
45
+ TGGeometryBench.report(
46
+ "batch_packed_vs_loop",
47
+ {
48
+ kind: kind,
49
+ n: size,
50
+ strategy: strategy,
51
+ mode: :packed_batch,
52
+ points_per_batch: points.length,
53
+ batches_per_sec: batch[:ops_per_sec] / points.length
54
+ },
55
+ stats: batch
56
+ )
25
57
  end
26
58
  end
27
59
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+
5
+ TGGeometryBench.say_header("ewkb_roundtrip")
6
+
7
+ geom = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], srid: 4326)
8
+ ewkb = geom.to_ewkb
9
+
10
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(5_000)) do |iterations|
11
+ iterations.times { TG::Geometry.parse_wkb(ewkb).to_ewkb }
12
+ end
13
+
14
+ TGGeometryBench.report(
15
+ "ewkb_roundtrip",
16
+ { library: :tg_geometry, operation: :parse_wkb_to_ewkb, payload_bytes: ewkb.bytesize },
17
+ stats: stats
18
+ )
19
+
20
+ if ENV["WITH_RGEO"]
21
+ begin
22
+ require "rgeo"
23
+ factory = RGeo::Cartesian.factory(srid: 4326)
24
+ rgeo_geom = factory.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")
25
+
26
+ rgeo_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(5_000)) do |iterations|
27
+ iterations.times { factory.parse_wkt(rgeo_geom.as_text) }
28
+ end
29
+
30
+ TGGeometryBench.report(
31
+ "ewkb_roundtrip",
32
+ { library: :rgeo, operation: :wkt_parse, payload_bytes: rgeo_geom.as_text.bytesize },
33
+ stats: rgeo_stats
34
+ )
35
+ rescue LoadError
36
+ warn "rgeo is not installed"
37
+ end
38
+ end
@@ -3,23 +3,30 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("falcon_concurrency")
6
- puts "No Falcon dependency is used here. This is a thread-read baseline for the immutable Index model."
7
- puts "Falcon/Async behavior remains an Pending decision until Roman approves a dedicated dependency/setup."
6
+ TGGeometryBench.report("falcon_concurrency_note", note: "thread_read_baseline_only_no_falcon_dependency")
8
7
 
9
- entries = TGGeometryBench.compact_entries(1_000)
8
+ entries_count = TGGeometryBench.env_integer("TGEOMETRY_BENCH_ENTRIES", 1_000, min: 1)
9
+ threads = TGGeometryBench.env_integer("TGEOMETRY_BENCH_THREADS", 4, min: 1)
10
+ entries = TGGeometryBench.compact_entries(entries_count)
10
11
  index = TGGeometryBench.build_index(entries, strategy: :rtree)
11
- threads = Integer(ENV.fetch("TGEOMETRY_BENCH_THREADS", "4"))
12
- iterations = TGGeometryBench.iterations(10_000)
12
+ points = TGGeometryBench.points_for(:compact)
13
13
 
14
- elapsed = Benchmark.realtime do
15
- threads.times.map do
14
+ stats = TGGeometryBench.measure_counted(
15
+ initial_iterations: TGGeometryBench.initial_iterations(2_000),
16
+ operations_per_iteration: threads
17
+ ) do |iterations_per_thread|
18
+ threads.times.map do |thread_index|
16
19
  Thread.new do
17
- iterations.times do |i|
18
- lon, lat = TGGeometryBench.points_for(:compact)[i % 3]
20
+ iterations_per_thread.times do |i|
21
+ lon, lat = points[(i + thread_index) % points.length]
19
22
  index.find_covering(lon, lat)
20
23
  end
21
24
  end
22
25
  end.each(&:join)
23
26
  end
24
27
 
25
- puts "threads=#{threads} iterations_per_thread=#{iterations} total_queries=#{threads * iterations} seconds=%.6f qps=%.2f" % [elapsed, (threads * iterations) / elapsed]
28
+ TGGeometryBench.report(
29
+ "falcon_concurrency",
30
+ { threads: threads, entries: entries_count, strategy: :rtree, operation: :find_covering },
31
+ stats: stats
32
+ )
@@ -40,7 +40,7 @@ end
40
40
 
41
41
  TGGeometryBench.say_header("feature_source")
42
42
 
43
- sizes = ENV["TGEOMETRY_BENCH_FULL"] == "1" ? [100, 1_000, 10_000, 50_000] : [100, 1_000]
43
+ sizes = TGGeometryBench.full? ? [100, 1_000, 10_000, 50_000] : [100, 1_000, 10_000]
44
44
 
45
45
  sizes.each do |size|
46
46
  json = FeatureSourceBenchData.feature_collection(size)
@@ -54,38 +54,64 @@ sizes.each do |size|
54
54
  direct_index = nil
55
55
  roundtrip_index = nil
56
56
 
57
- ruby_time = Benchmark.realtime do
58
- ruby_entries = FeatureSourceBenchData.ruby_json_parse_entries(path)
57
+ ruby = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
58
+ iterations.times { ruby_entries = FeatureSourceBenchData.ruby_json_parse_entries(path) }
59
59
  end
60
60
 
61
- read_entries_time = Benchmark.realtime do
62
- feature_entries = TG::Geometry::FeatureSource.read_entries_file(path, id: ["properties", "@id"])
61
+ read_entries = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
62
+ iterations.times do
63
+ feature_entries = TG::Geometry::FeatureSource.read_entries_file(path, id: ["properties", "@id"])
64
+ end
63
65
  end
64
66
 
65
- read_features_time = Benchmark.realtime do
66
- feature_rows = TG::Geometry::FeatureSource.read_features_file(path, id: ["properties", "@id"])
67
+ read_features = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
68
+ iterations.times do
69
+ feature_rows = TG::Geometry::FeatureSource.read_features_file(path, id: ["properties", "@id"])
70
+ end
67
71
  end
68
72
 
69
- direct_index_time = Benchmark.realtime do
70
- direct_index = TG::Geometry::FeatureSource.build_index_file(path, id: ["properties", "@id"], strategy: :rtree)
73
+ direct_index_stat = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
74
+ iterations.times do
75
+ direct_index = TG::Geometry::FeatureSource.build_index_file(path, id: ["properties", "@id"], strategy: :rtree)
76
+ end
71
77
  end
72
78
 
73
- roundtrip_index_time = Benchmark.realtime do
74
- roundtrip_index = TG::Geometry::Index.build(feature_entries, via: :geojson, strategy: :rtree)
79
+ # Ensure the roundtrip benchmark does not accidentally include read_entries.
80
+ feature_entries ||= TG::Geometry::FeatureSource.read_entries_file(path, id: ["properties", "@id"])
81
+
82
+ roundtrip_index_stat = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
83
+ iterations.times do
84
+ roundtrip_index = TG::Geometry::Index.build(feature_entries, via: :geojson, strategy: :rtree)
85
+ end
75
86
  end
76
87
 
77
- puts "n=#{size} ruby_json_parse_sec=%.6f read_entries_sec=%.6f read_features_sec=%.6f build_index_direct_sec=%.6f build_index_from_entries_sec=%.6f entries=%d features=%d direct_size=%d roundtrip_size=%d rss_kb=%d" % [
78
- ruby_time,
79
- read_entries_time,
80
- read_features_time,
81
- direct_index_time,
82
- roundtrip_index_time,
83
- ruby_entries.length,
84
- feature_rows.length,
85
- direct_index.size,
86
- roundtrip_index.size,
87
- TGGeometryBench.rss_kb
88
- ]
88
+ read_plus_roundtrip_sec = read_entries[:median_sec] + roundtrip_index_stat[:median_sec]
89
+ read_plus_roundtrip_ops_per_sec = read_plus_roundtrip_sec.positive? ? read_entries[:iterations] / read_plus_roundtrip_sec : 0.0
90
+
91
+ common = {
92
+ n: size,
93
+ json_bytes: json.bytesize,
94
+ ruby_entries: ruby_entries.length,
95
+ feature_entries: feature_entries.length,
96
+ feature_rows: feature_rows.length,
97
+ direct_size: direct_index.size,
98
+ roundtrip_size: roundtrip_index.size,
99
+ rss_kb: TGGeometryBench.rss_kb
100
+ }
101
+
102
+ TGGeometryBench.report("feature_source", common.merge(mode: :ruby_json_parse_entries), stats: ruby)
103
+ TGGeometryBench.report("feature_source", common.merge(mode: :read_entries_file), stats: read_entries)
104
+ TGGeometryBench.report("feature_source", common.merge(mode: :read_features_file), stats: read_features)
105
+ TGGeometryBench.report("feature_source", common.merge(mode: :build_index_direct), stats: direct_index_stat)
106
+ TGGeometryBench.report("feature_source", common.merge(mode: :build_index_from_prepared_entries), stats: roundtrip_index_stat)
107
+ TGGeometryBench.report(
108
+ "feature_source_end_to_end",
109
+ common.merge(
110
+ mode: :read_entries_plus_build_index,
111
+ median_sec: read_plus_roundtrip_sec,
112
+ ops_per_sec: read_plus_roundtrip_ops_per_sec
113
+ )
114
+ )
89
115
  ensure
90
116
  file.close!
91
117
  end
@@ -3,25 +3,67 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("flat_vs_rtree")
6
- iterations = TGGeometryBench.iterations(1_000)
7
6
 
8
7
  %i[compact long_thin overlapping].each do |kind|
9
8
  TGGeometryBench.sizes.each do |size|
10
9
  entries = TGGeometryBench.entries_for(kind, size)
11
10
 
12
- build_flat = Benchmark.realtime { @flat = TGGeometryBench.build_index(entries, strategy: :flat) }
13
- build_rtree = Benchmark.realtime { @rtree = TGGeometryBench.build_index(entries, strategy: :rtree) }
11
+ flat = nil
12
+ rtree = nil
14
13
 
15
- TGGeometryBench.points_for(kind).each do |lon, lat|
16
- flat_time = Benchmark.realtime { iterations.times { @flat.find_covering(lon, lat) } }
17
- rtree_time = Benchmark.realtime { iterations.times { @rtree.find_covering(lon, lat) } }
18
- puts "kind=#{kind} n=#{size} query=point lon=#{lon} lat=#{lat} flat_sec=%.6f rtree_sec=%.6f flat_qps=%.2f rtree_qps=%.2f build_flat=%.6f build_rtree=%.6f" % [flat_time, rtree_time, iterations / flat_time, iterations / rtree_time, build_flat, build_rtree]
14
+ flat_build = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.25) do |iterations|
15
+ iterations.times { flat = TGGeometryBench.build_index(entries, strategy: :flat) }
19
16
  end
17
+ rtree_build = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.25) do |iterations|
18
+ iterations.times { rtree = TGGeometryBench.build_index(entries, strategy: :rtree) }
19
+ end
20
+
21
+ # Rebuild once outside the build benchmark so query numbers do not depend on
22
+ # the last object produced by calibration/repeats.
23
+ flat = TGGeometryBench.build_index(entries, strategy: :flat)
24
+ rtree = TGGeometryBench.build_index(entries, strategy: :rtree)
25
+
26
+ TGGeometryBench.report("flat_vs_rtree_build", { kind: kind, n: size, strategy: :flat }, stats: flat_build)
27
+ TGGeometryBench.report("flat_vs_rtree_build", { kind: kind, n: size, strategy: :rtree }, stats: rtree_build)
28
+
29
+ TGGeometryBench.points_for(kind).each_with_index do |(lon, lat), point_index|
30
+ flat_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(2_000)) do |iterations|
31
+ iterations.times { flat.find_covering(lon, lat) }
32
+ end
33
+ rtree_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(2_000)) do |iterations|
34
+ iterations.times { rtree.find_covering(lon, lat) }
35
+ end
36
+
37
+ TGGeometryBench.report(
38
+ "flat_vs_rtree_point",
39
+ { kind: kind, n: size, strategy: :flat, point_index: point_index, lon: lon, lat: lat },
40
+ stats: flat_stats
41
+ )
42
+ TGGeometryBench.report(
43
+ "flat_vs_rtree_point",
44
+ { kind: kind, n: size, strategy: :rtree, point_index: point_index, lon: lon, lat: lat },
45
+ stats: rtree_stats
46
+ )
47
+ end
48
+
49
+ TGGeometryBench.rects_for(kind).each_with_index do |rect, rect_index|
50
+ flat_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(200)) do |iterations|
51
+ iterations.times { flat.intersecting_rect(*rect) }
52
+ end
53
+ rtree_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(200)) do |iterations|
54
+ iterations.times { rtree.intersecting_rect(*rect) }
55
+ end
20
56
 
21
- TGGeometryBench.rects_for(kind).each do |rect|
22
- flat_time = Benchmark.realtime { iterations.times { @flat.intersecting_rect(*rect) } }
23
- rtree_time = Benchmark.realtime { iterations.times { @rtree.intersecting_rect(*rect) } }
24
- puts "kind=#{kind} n=#{size} query=rect rect=#{rect.join(',')} flat_sec=%.6f rtree_sec=%.6f flat_qps=%.2f rtree_qps=%.2f" % [flat_time, rtree_time, iterations / flat_time, iterations / rtree_time]
57
+ TGGeometryBench.report(
58
+ "flat_vs_rtree_rect",
59
+ { kind: kind, n: size, strategy: :flat, rect_index: rect_index, rect: rect },
60
+ stats: flat_stats
61
+ )
62
+ TGGeometryBench.report(
63
+ "flat_vs_rtree_rect",
64
+ { kind: kind, n: size, strategy: :rtree, rect_index: rect_index, rect: rect },
65
+ stats: rtree_stats
66
+ )
25
67
  end
26
68
  end
27
69
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+
5
+ TGGeometryBench.say_header("geom_query")
6
+
7
+ COUNTS = (TGGeometryBench.full? ? [1_000, 10_000, 50_000] : [1_000, 10_000]).freeze
8
+ STRATEGIES = %i[flat rtree].freeze
9
+
10
+ def box(x, y, size = 1.0)
11
+ TG::Geometry.polygon([[x, y], [x + size, y], [x + size, y + size], [x, y + size], [x, y]])
12
+ end
13
+
14
+ COUNTS.each do |count|
15
+ entries = count.times.map do |i|
16
+ x = (i % 100).to_f * 2.0
17
+ y = (i / 100).to_f * 2.0
18
+ [i, box(x, y)]
19
+ end
20
+
21
+ queries = {
22
+ small: box(10.5, 10.5, 2.0),
23
+ large: box(0.5, 0.5, 120.0),
24
+ miss: box(-1_000.0, -1_000.0, 1.0)
25
+ }
26
+
27
+ STRATEGIES.each do |strategy|
28
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: strategy)
29
+
30
+ queries.each do |query_name, query_geom|
31
+ initial = query_name == :large ? 100 : 1_000
32
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
33
+ iterations.times { index.intersecting_geom_ids(query_geom) }
34
+ end
35
+
36
+ TGGeometryBench.report(
37
+ "geom_query",
38
+ { n: count, strategy: strategy, query: query_name, method: :intersecting_geom_ids },
39
+ stats: stats
40
+ )
41
+ end
42
+ end
43
+ end
@@ -3,12 +3,9 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("gvl_threshold")
6
- puts "First release intentionally performs parse/write/batch/query with GVL held."
7
- puts "This harness records baseline parse wall time only; it does not enable no-GVL execution."
6
+ TGGeometryBench.report("gvl_threshold_note", note: "baseline_only_gvl_is_held_no_public_release_gvl_knob")
8
7
 
9
- # Build one valid WKT polygon close to the requested byte size. The previous
10
- # implementation accidentally benchmarked the same tiny 39-byte polygon for all
11
- # target sizes because it used Array(...).first.
8
+ # Build one valid WKT polygon close to the requested byte size.
12
9
  def polygon_wkt_at_least(target_bytes)
13
10
  points_count = [4, target_bytes / 38].max
14
11
 
@@ -27,15 +24,17 @@ def polygon_wkt_at_least(target_bytes)
27
24
  end
28
25
  end
29
26
 
30
- sizes = [128, 1_024, 16_384, 262_144]
31
- iterations = TGGeometryBench.iterations(2_000)
32
-
33
- sizes.each do |target_bytes|
27
+ [128, 1_024, 16_384, 262_144].each do |target_bytes|
34
28
  payload = polygon_wkt_at_least(target_bytes)
29
+ initial = target_bytes >= 262_144 ? 10 : 500
35
30
 
36
- time = Benchmark.realtime do
31
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
37
32
  iterations.times { TG::Geometry.parse_wkt(payload) }
38
33
  end
39
34
 
40
- puts "target_bytes=#{target_bytes} payload_bytes=#{payload.bytesize} iterations=#{iterations} seconds=%.6f ops_per_sec=%.2f" % [time, iterations / time]
35
+ TGGeometryBench.report(
36
+ "gvl_threshold_parse_wkt",
37
+ { target_bytes: target_bytes, payload_bytes: payload.bytesize },
38
+ stats: stats
39
+ )
41
40
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+
5
+ TGGeometryBench.say_header("nearest_segment")
6
+
7
+ [100, 1_000, 10_000].each do |count|
8
+ points = count.times.map do |i|
9
+ angle = 2.0 * Math::PI * i / count
10
+ [Math.cos(angle), Math.sin(angle)]
11
+ end
12
+ points << points.first
13
+ ring = TG::Geometry.polygon(points).polygon.exterior_ring
14
+
15
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(10_000)) do |iterations|
16
+ iterations.times { ring.nearest_segment(0.25, 0.33) }
17
+ end
18
+
19
+ TGGeometryBench.report(
20
+ "nearest_segment",
21
+ { segments: ring.num_segments, query: "0.25:0.33" },
22
+ stats: stats
23
+ )
24
+ end
@@ -8,10 +8,23 @@ TGGeometryBench.say_header("objectspace_memsize")
8
8
  %i[compact long_thin overlapping].each do |kind|
9
9
  TGGeometryBench.sizes.each do |size|
10
10
  entries = TGGeometryBench.entries_for(kind, size)
11
+ TGGeometryBench.gc_start
11
12
  flat = TGGeometryBench.build_index(entries, strategy: :flat)
13
+ TGGeometryBench.gc_start
12
14
  rtree = TGGeometryBench.build_index(entries, strategy: :rtree)
13
15
  geom = TG::Geometry.parse_geojson(entries.first.last)
14
16
 
15
- puts "kind=#{kind} n=#{size} geom_memsize=#{ObjectSpace.memsize_of(geom)} flat_memsize=#{ObjectSpace.memsize_of(flat)} rtree_memsize=#{ObjectSpace.memsize_of(rtree)}"
17
+ TGGeometryBench.report(
18
+ "objectspace_memsize",
19
+ {
20
+ kind: kind,
21
+ n: size,
22
+ geom_memsize: ObjectSpace.memsize_of(geom),
23
+ flat_memsize: ObjectSpace.memsize_of(flat),
24
+ rtree_memsize: ObjectSpace.memsize_of(rtree),
25
+ rtree_over_flat: ObjectSpace.memsize_of(rtree) - ObjectSpace.memsize_of(flat),
26
+ rss_kb: TGGeometryBench.rss_kb
27
+ }
28
+ )
16
29
  end
17
30
  end
@@ -21,18 +21,21 @@ geojson = '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]
21
21
  wkb = TG::Geometry.parse_wkt(small).to_wkb
22
22
 
23
23
  cases = {
24
- "wkt:small" => [small, :wkt],
25
- "geojson:small" => [geojson, :geojson],
26
- "wkb:small" => [wkb, :wkb],
27
- "wkt:medium" => [medium, :wkt],
28
- "wkt:large" => [large, :wkt]
24
+ "wkt_small" => [small, :wkt, 5_000],
25
+ "geojson_small" => [geojson, :geojson, 5_000],
26
+ "wkb_small" => [wkb, :wkb, 5_000],
27
+ "wkt_medium" => [medium, :wkt, 500],
28
+ "wkt_large" => [large, :wkt, 50]
29
29
  }
30
30
 
31
- iterations = TGGeometryBench.iterations(10_000)
32
-
33
- cases.each do |name, (payload, format)|
34
- time = Benchmark.realtime do
31
+ cases.each do |name, (payload, format, initial)|
32
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
35
33
  iterations.times { TG::Geometry.parse(payload, format: format) }
36
34
  end
37
- puts "%s iterations=%d seconds=%.6f ops_per_sec=%.2f" % [name, iterations, time, iterations / time]
35
+
36
+ TGGeometryBench.report(
37
+ "parse_throughput",
38
+ { case: name, format: format, payload_bytes: payload.bytesize },
39
+ stats: stats
40
+ )
38
41
  end
@@ -4,27 +4,28 @@ require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("rss_stability")
6
6
 
7
- total_queries = Integer(ENV.fetch("TGEOMETRY_RSS_QUERIES", "10_000_000"))
8
- rebuilds = Integer(ENV.fetch("TGEOMETRY_RSS_REBUILDS", "100"))
9
- entries_count = Integer(ENV.fetch("TGEOMETRY_RSS_ENTRIES", "1_000"))
10
- max_drift_kb = Integer(ENV.fetch("TGEOMETRY_RSS_MAX_DRIFT_KB", "51_200"))
7
+ total_queries = TGGeometryBench.env_integer("TGEOMETRY_RSS_QUERIES", 10_000_000, min: 1)
8
+ rebuilds = TGGeometryBench.env_integer("TGEOMETRY_RSS_REBUILDS", 100, min: 1)
9
+ entries_count = TGGeometryBench.env_integer("TGEOMETRY_RSS_ENTRIES", 1_000, min: 1)
10
+ max_drift_kb = TGGeometryBench.env_integer("TGEOMETRY_RSS_MAX_DRIFT_KB", 51_200, min: 0)
11
11
 
12
12
  queries_per_rebuild = (total_queries / rebuilds).clamp(1, total_queries)
13
13
  entries = TGGeometryBench.compact_entries(entries_count)
14
14
  points = TGGeometryBench.points_for(:compact)
15
15
  packed_batch = TGGeometryBench.packed_points(points * 10)
16
16
 
17
- GC.start
18
- GC.start
17
+ TGGeometryBench.gc_start
18
+ TGGeometryBench.gc_start
19
19
  start_rss = TGGeometryBench.rss_kb
20
20
  peak_rss = start_rss
21
21
  samples = []
22
22
 
23
23
  queries_executed = 0
24
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ started_at = TGGeometryBench.monotonic
25
25
 
26
26
  rebuilds.times do |cycle|
27
- index = TGGeometryBench.build_index(entries, strategy: (cycle.even? ? :flat : :rtree))
27
+ strategy = cycle.even? ? :flat : :rtree
28
+ index = TGGeometryBench.build_index(entries, strategy: strategy)
28
29
 
29
30
  queries_per_rebuild.times do |q|
30
31
  lon, lat = points[(q + cycle) % points.length]
@@ -39,29 +40,39 @@ rebuilds.times do |cycle|
39
40
 
40
41
  index = nil
41
42
 
42
- if (cycle % 10).zero?
43
- GC.start
44
- rss = TGGeometryBench.rss_kb
45
- peak_rss = [peak_rss, rss].max
46
- samples << [cycle, queries_executed, rss]
47
- end
43
+ next unless (cycle % 10).zero?
44
+
45
+ TGGeometryBench.gc_start
46
+ rss = TGGeometryBench.rss_kb
47
+ peak_rss = [peak_rss, rss].max
48
+ samples << [cycle, queries_executed, rss]
48
49
  end
49
50
 
50
- GC.start
51
- GC.start
51
+ TGGeometryBench.gc_start
52
+ TGGeometryBench.gc_start
52
53
  finish_rss = TGGeometryBench.rss_kb
53
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
54
+ elapsed = TGGeometryBench.monotonic - started_at
54
55
  drift_kb = finish_rss - start_rss
55
56
 
56
- puts format(
57
- "queries=%d rebuilds=%d entries=%d elapsed_s=%.2f start_rss_kb=%d peak_rss_kb=%d finish_rss_kb=%d drift_kb=%d",
58
- queries_executed, rebuilds, entries_count, elapsed,
59
- start_rss, peak_rss, finish_rss, drift_kb
57
+ TGGeometryBench.report(
58
+ "rss_stability",
59
+ {
60
+ queries: queries_executed,
61
+ rebuilds: rebuilds,
62
+ entries: entries_count,
63
+ elapsed_sec: elapsed,
64
+ qps: queries_executed / elapsed,
65
+ start_rss_kb: start_rss,
66
+ peak_rss_kb: peak_rss,
67
+ finish_rss_kb: finish_rss,
68
+ drift_kb: drift_kb,
69
+ max_drift_kb: max_drift_kb,
70
+ sample_count: samples.length
71
+ }
60
72
  )
61
73
 
62
- if samples.length > 1
63
- puts "samples (cycle, queries, rss_kb):"
64
- samples.each { |row| puts " #{row.inspect}" }
74
+ samples.each do |cycle, queries, rss|
75
+ TGGeometryBench.report("rss_stability_sample", cycle: cycle, queries: queries, rss_kb: rss)
65
76
  end
66
77
 
67
78
  if max_drift_kb.positive? && drift_kb > max_drift_kb
@@ -0,0 +1,23 @@
1
+ # Geometry-based index queries
2
+
3
+ v0.3.0 adds geometry query methods to `TG::Geometry::Index`:
4
+
5
+ | Method | Predicate direction | Boundary semantics |
6
+ | --- | --- | --- |
7
+ | `intersecting_geom_ids(query)` | `stored_geom` intersects `query` | any intersection |
8
+ | `covering_geom_ids(query)` | `stored_geom` covers `query` | boundary included |
9
+ | `containing_geom_ids(query)` | `stored_geom` contains `query` | strict interior; boundary excluded |
10
+
11
+ The direction is always from the stored geometry to the query geometry. For example, `covering_geom_ids(query)` asks which indexed geometries cover the query.
12
+
13
+ The `predicate:` option on `Index.build` affects only the legacy point-based methods:
14
+
15
+ - `find_covering(x, y)`
16
+ - `covering_ids(x, y)`
17
+ - `covering_ids_batch_packed(packed_doubles)`
18
+
19
+ It does not affect `intersecting_geom_ids`, `covering_geom_ids`, or `containing_geom_ids`.
20
+
21
+ Results are arrays of ids in insertion order. Duplicate ids are preserved if duplicate ids were inserted.
22
+
23
+ In v0.3.0 these operations run under the GVL. The heavy C phase is structured without Ruby API calls so it can be made no-GVL-safe later after benchmarking, but there is no public `release_gvl:` knob.
data/docs/LIMITATIONS.md CHANGED
@@ -4,16 +4,18 @@
4
4
 
5
5
  Not included:
6
6
 
7
- - geocoding;
8
- - routing;
9
- - projections;
10
- - geodesic distance/area;
11
- - buffer / union / difference / overlay result geometry operations;
12
- - nearest POI indexing;
13
- - public callback/search APIs;
14
- - Ractor support claim;
15
- - no-GVL execution claim;
16
- - automatic strategy selection.
7
+ - Geodesic / Haversine distance;
8
+ - Projection / reprojection;
9
+ - Buffer / union / difference / convex hull;
10
+ - Index nearest_ids (KNN);
11
+ - GeoBIN bbox helpers;
12
+ - Streaming FeatureSource;
13
+ - Index serialization / mmap;
14
+ - Ractor support;
15
+ - Windows / JRuby;
16
+ - Write-side ActiveRecordType / AR scopes / migrations;
17
+ - Z/M variants of array constructors;
18
+ - Public `release_gvl:` option.
17
19
 
18
20
  TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, and perimeter-style values are in input coordinate units, not meters.
19
21
 
@@ -0,0 +1,17 @@
1
+ # Nearest segment
2
+
3
+ `Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` compute the nearest segment to a point in planar XY space.
4
+
5
+ ```ruby
6
+ nearest = ring.nearest_segment(x, y)
7
+ nearest.segment # TG::Geometry::Segment
8
+ nearest.index # Integer segment index in the parent line/ring
9
+ nearest.distance # Float
10
+ nearest.point # [x, y] projection onto the segment
11
+ ```
12
+
13
+ Distance is Euclidean distance in input coordinate units. It is not meters unless the input coordinate system is already meters.
14
+
15
+ Degenerate segments (`a == b`) are handled as point distance. The projection point for a degenerate segment is the segment endpoint.
16
+
17
+ When several segments have the same distance, tie-break behaviour follows tg iteration order. Code should not rely on a specific equal-distance segment.