From 0017e4b759fd6a1b9ad41b5fb8babaef23cfb86c Mon Sep 17 00:00:00 2001 From: Jilles van Gurp Date: Wed, 10 Jun 2026 00:07:16 +0200 Subject: [PATCH] Fix fence evaluation for polygon regions and derived scopes --- internal/hub/metadata_cache.go | 31 +-- internal/hub/metadata_cache_test.go | 140 ++++++++++++++ internal/hub/service.go | 42 +++-- internal/hub/service_test.go | 282 ++++++++++++++++++++++++++++ 4 files changed, 466 insertions(+), 29 deletions(-) diff --git a/internal/hub/metadata_cache.go b/internal/hub/metadata_cache.go index b95f70d..96a70c0 100644 --- a/internal/hub/metadata_cache.go +++ b/internal/hub/metadata_cache.go @@ -698,22 +698,23 @@ func fenceMatchesFloor(fence gen.Fence, location gen.Location) bool { func fenceBoundingRect(fence gen.Fence) (rtreego.Rect, bool) { if p, err := fence.Region.AsPoint(); err == nil { - center, err := point2D(p) - if err != nil { - return rtreego.Rect{}, false - } - radius := 0.0 - if fence.Radius != nil { - radius = float64(*fence.Radius) - } - rect, err := rtreego.NewRect( - rtreego.Point{center[0] - radius, center[1] - radius}, - []float64{radius * 2, radius * 2}, - ) - if err != nil { - return rtreego.Rect{}, false + if p.Type == "Point" { + center, err := point2D(p) + if err == nil { + radius := 0.0 + if fence.Radius != nil { + radius = float64(*fence.Radius) + } + rect, err := rtreego.NewRect( + rtreego.Point{center[0] - radius, center[1] - radius}, + []float64{radius * 2, radius * 2}, + ) + if err != nil { + return rtreego.Rect{}, false + } + return rect, true + } } - return rect, true } polygon, err := fence.Region.AsPolygon() if err != nil || len(polygon.Coordinates) == 0 || len(polygon.Coordinates[0]) == 0 { diff --git a/internal/hub/metadata_cache_test.go b/internal/hub/metadata_cache_test.go index 8f8280d..e60ca0e 100644 --- a/internal/hub/metadata_cache_test.go +++ b/internal/hub/metadata_cache_test.go @@ -130,6 +130,146 @@ func TestMetadataCacheFenceCandidatesMatchLocationFloor(t *testing.T) { } } +func TestMetadataCacheFenceCandidatesMatchWGS84PolygonFence(t *testing.T) { + t.Parallel() + + fenceID, err := uuid.Parse("019eab42-3d91-7b8a-8b64-1ca8e2c6055d") + if err != nil { + t.Fatalf("parse fence id failed: %v", err) + } + fence := productionAreaFenceFixture(t, fenceID) + + cache, err := NewMetadataCache(context.Background(), fakeQueries{ + listZonesFn: metadataZoneList(t), + listFencesFn: metadataFenceList(t, fence), + listTrackablesFn: metadataTrackableList(t), + listProvidersFn: metadataProviderList(t), + }) + if err != nil { + t.Fatalf("NewMetadataCache failed: %v", err) + } + + location := testLocationWithCoordinates(t, stringPtrValueRef("EPSG:4326"), "simulated-tag-1", [2]float32{8.90318, 52.01776}) + scopeKey, ok, err := cache.current().locationFenceScopeKey(location) + if err != nil { + t.Fatalf("locationFenceScopeKey failed: %v", err) + } + if !ok { + t.Fatal("expected location fence scope key") + } + fenceKey, ok := fenceScopeKey(fence) + if !ok { + t.Fatal("expected fence scope key") + } + if scopeKey != fenceKey { + t.Fatalf("scope mismatch: location=%s fence=%s", scopeKey, fenceKey) + } + rect, ok := fenceBoundingRect(fence) + if !ok { + t.Fatal("expected fence bounding rect") + } + point, err := point2D(location.Position) + if err != nil { + t.Fatalf("point2D failed: %v", err) + } + minX := rect.PointCoord(0) + minY := rect.PointCoord(1) + maxX := minX + rect.LengthsCoord(0) + maxY := minY + rect.LengthsCoord(1) + if point[0] < minX || point[0] > maxX || point[1] < minY || point[1] > maxY { + t.Fatalf("point %v outside rect [%f,%f]-[%f,%f]", point, minX, minY, maxX, maxY) + } + candidates, err := cache.FenceCandidates(location) + if err != nil { + t.Fatalf("FenceCandidates failed: %v", err) + } + if len(candidates) != 1 || candidates[0].Id != fence.Id { + t.Fatalf("unexpected WGS84 polygon fence candidates: %+v", candidates) + } +} + +func TestMetadataCacheFenceCandidatesSupportPointAndPolygonAcrossScopes(t *testing.T) { + t.Parallel() + + zone := testZoneWithForeignID(t, uuid.New(), "uwb", "zone-a", [2]float32{0, 0}, nil, nil) + localCRS := "local" + wgsCRS := "EPSG:4326" + + localPointFence := testPointFence(t, uuid.New(), [2]float32{5, 5}, 2) + localPointFence.Crs = &localCRS + localPointFence.ZoneId = stringPtrValueRef(zone.Id.String()) + + localPolygonFence := testPolygonFence(t, uuid.New(), [][2]float32{ + {4, 4}, {6, 4}, {6, 6}, {4, 6}, {4, 4}, + }) + localPolygonFence.Crs = &localCRS + localPolygonFence.ZoneId = stringPtrValueRef(zone.Id.String()) + + wgsPointFence := testPointFence(t, uuid.New(), [2]float32{8.5411, 47.3744}, 0.0002) + wgsPointFence.Crs = &wgsCRS + + wgsPolygonFence := testPolygonFence(t, uuid.New(), [][2]float32{ + {8.5410, 47.3743}, {8.5412, 47.3743}, {8.5412, 47.3745}, {8.5410, 47.3745}, {8.5410, 47.3743}, + }) + wgsPolygonFence.Crs = &wgsCRS + + cache, err := NewMetadataCache(context.Background(), fakeQueries{ + listZonesFn: metadataZoneList(t, zone), + listFencesFn: metadataFenceList(t, localPointFence, localPolygonFence, wgsPointFence, wgsPolygonFence), + listTrackablesFn: metadataTrackableList(t), + listProvidersFn: metadataProviderList(t), + }) + if err != nil { + t.Fatalf("NewMetadataCache failed: %v", err) + } + + tests := []struct { + name string + location gen.Location + expectedID [16]byte + }{ + { + name: "local point", + location: testLocationWithCoordinates(t, &localCRS, zone.Id.String(), [2]float32{5, 5}), + expectedID: localPointFence.Id, + }, + { + name: "local polygon", + location: testLocationWithCoordinates(t, &localCRS, zone.Id.String(), [2]float32{5, 5}), + expectedID: localPolygonFence.Id, + }, + { + name: "wgs point", + location: testLocationWithCoordinates(t, &wgsCRS, "provider-a", [2]float32{8.5411, 47.3744}), + expectedID: wgsPointFence.Id, + }, + { + name: "wgs polygon", + location: testLocationWithCoordinates(t, &wgsCRS, "provider-a", [2]float32{8.5411, 47.3744}), + expectedID: wgsPolygonFence.Id, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + candidates, err := cache.FenceCandidates(tc.location) + if err != nil { + t.Fatalf("FenceCandidates failed: %v", err) + } + found := false + for _, candidate := range candidates { + if candidate.Id == tc.expectedID { + found = true + break + } + } + if !found { + t.Fatalf("expected fence %s in candidates, got %+v", uuid.UUID(tc.expectedID).String(), candidates) + } + }) + } +} + func TestMetadataCacheReconcileDiffsCreateUpdateDelete(t *testing.T) { t.Parallel() diff --git a/internal/hub/service.go b/internal/hub/service.go index 737a25d..d43bc35 100644 --- a/internal/hub/service.go +++ b/internal/hub/service.go @@ -1176,6 +1176,9 @@ func (s *Service) processDerivedLocation(ctx context.Context, location gen.Locat case ScopeLocal: wgs84Location, ok, err := view.WGS84(ctx) if err == nil && ok { + if err := s.publishFenceEvents(ctx, *wgs84Location); err != nil { + return err + } if emit { if err := s.publishLocationScope(ctx, *wgs84Location, ScopeEPSG4326); err != nil { return err @@ -1212,6 +1215,9 @@ func (s *Service) processDerivedLocation(ctx context.Context, location gen.Locat if emit { localLocation, ok, err := view.Local(ctx) if err == nil && ok { + if err := s.publishFenceEvents(ctx, *localLocation); err != nil { + return err + } if err := s.publishLocationScope(ctx, *localLocation, ScopeLocal); err != nil { return err } @@ -1219,6 +1225,13 @@ func (s *Service) processDerivedLocation(ctx context.Context, location gen.Locat return err } } + } else { + localLocation, ok, err := view.Local(ctx) + if err == nil && ok { + if err := s.publishFenceEvents(ctx, *localLocation); err != nil { + return err + } + } } } return nil @@ -2903,21 +2916,22 @@ func point2D(point gen.Point) ([2]float64, error) { func fenceContainmentForPoint(fence gen.Fence, point [2]float64) (fenceContainment, error) { if p, err := fence.Region.AsPoint(); err == nil { - center, err := point2D(p) - if err != nil { - return fenceContainment{}, err - } - radius := 0.0 - if fence.Radius != nil { - radius = float64(*fence.Radius) - } - dx := point[0] - center[0] - dy := point[1] - center[1] - distance := math.Sqrt(dx*dx + dy*dy) - if distance <= radius { - return fenceContainment{Inside: true}, nil + if p.Type == "Point" { + center, err := point2D(p) + if err == nil { + radius := 0.0 + if fence.Radius != nil { + radius = float64(*fence.Radius) + } + dx := point[0] - center[0] + dy := point[1] - center[1] + distance := math.Sqrt(dx*dx + dy*dy) + if distance <= radius { + return fenceContainment{Inside: true}, nil + } + return fenceContainment{OutsideDistance: distance - radius}, nil + } } - return fenceContainment{OutsideDistance: distance - radius}, nil } polygon, err := fence.Region.AsPolygon() if err != nil { diff --git a/internal/hub/service_test.go b/internal/hub/service_test.go index 3e5b5cc..ea33f07 100644 --- a/internal/hub/service_test.go +++ b/internal/hub/service_test.go @@ -1194,6 +1194,232 @@ func TestProcessDerivedLocationEvaluatesGeofencesWhenPublicationSuppressed(t *te } } +func TestProcessDerivedLocationEvaluatesLocalFencesForWGS84Input(t *testing.T) { + t.Parallel() + + bus := NewEventBus() + ch, unsubscribe := bus.Subscribe(8) + defer unsubscribe() + state := NewProcessingState(time.Now) + zone := georeferencedZoneFixture(t, 47.3744, 8.5411) + localCRS := "local" + fence := testPointFence(t, uuid.New(), [2]float32{5, 5}, 2) + fence.Crs = &localCRS + fence.ZoneId = stringPtrValueRef(zone.Id.String()) + crs := "EPSG:4326" + location := testLocationWithCoordinates(t, &crs, zone.Id.String(), [2]float32{8.54115, 47.37445}) + trackables := []string{"trackable-a"} + location.Trackables = &trackables + + service := &Service{ + bus: bus, + state: state, + metadata: &MetadataCache{snapshot: newMetadataSnapshot([]zoneRecord{{Zone: zone, Signature: "zone"}}, []fenceRecord{{Fence: fence, Signature: "fence"}}, nil, nil)}, + crsTransformer: transform.NewCRSTransformer(), + transformCache: transform.NewCache(), + cfg: Config{LocationTTL: time.Minute}, + } + + if err := service.processDerivedLocation(context.Background(), location, true); err != nil { + t.Fatalf("processDerivedLocation failed: %v", err) + } + + events := collectEvents(ch, 3) + count := 0 + for _, event := range events { + if event.Kind == EventFenceEvent { + count++ + } + } + if count != 1 { + t.Fatalf("expected one fence event, got %d", count) + } +} + +func TestProcessLocationsEmitsFenceEventForProductionAreaScenario(t *testing.T) { + t.Parallel() + + bus := NewEventBus() + ch, unsubscribe := bus.Subscribe(16) + defer unsubscribe() + + fenceID, err := uuid.Parse("019eab42-3d91-7b8a-8b64-1ca8e2c6055d") + if err != nil { + t.Fatalf("parse fence id failed: %v", err) + } + trackableID, err := uuid.Parse("019eac84-f826-758a-b1f5-7bcb3cfa840a") + if err != nil { + t.Fatalf("parse trackable id failed: %v", err) + } + providerIDs := gen.StringIdList{"simulated-tag-1"} + trackable := gen.Trackable{ + Id: uuidAsOpenAPI(trackableID), + Type: gen.TrackableTypeVirtual, + Name: stringPtrValueRef("plugfest-godzilla"), + LocationProviders: &providerIDs, + } + provider := gen.LocationProvider{ + Id: "simulated-tag-1", + Type: "virtual", + Name: stringPtrValueRef("simulated-tag-1"), + } + fence := productionAreaFenceFixture(t, fenceID) + crs := "EPSG:4326" + location := testLocationWithCoordinates(t, &crs, "simulated-tag-1", [2]float32{8.90318, 52.01776}) + location.ProviderId = "simulated-tag-1" + location.ProviderType = "virtual" + + service := &Service{ + bus: bus, + state: NewProcessingState(time.Now), + metadata: &MetadataCache{snapshot: newMetadataSnapshot(nil, []fenceRecord{{Fence: fence, Signature: "fence"}}, []trackableRecord{{Trackable: trackable}}, []providerRecord{{Provider: provider, Signature: "provider"}})}, + crsTransformer: transform.NewCRSTransformer(), + transformCache: transform.NewCache(), + cfg: Config{LocationTTL: time.Minute, NativeLocationBuffer: 16, DerivedLocationBuffer: 16}, + stats: runtimeStatsFromBus(bus), + logger: zapTestLogger(t), + decisionStage: passthroughDecisionStage{}, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + service.Start(ctx) + + if err := service.ProcessLocations(context.Background(), []gen.Location{location}); err != nil { + t.Fatalf("ProcessLocations failed: %v", err) + } + + events := collectEvents(ch, 4) + var fenceCount int + var matchingFence bool + var matchingTrackable bool + for _, event := range events { + if event.Kind != EventFenceEvent { + continue + } + fenceCount++ + envelope, err := Decode[FenceEventEnvelope](event) + if err != nil { + t.Fatalf("decode fence event failed: %v", err) + } + if envelope.Event.FenceId == uuidAsOpenAPI(fenceID) { + matchingFence = true + } + if envelope.Event.TrackableId != nil && *envelope.Event.TrackableId == trackableID.String() { + matchingTrackable = true + } + } + if fenceCount == 0 { + kinds := make([]EventKind, 0, len(events)) + for _, event := range events { + kinds = append(kinds, event.Kind) + } + t.Fatalf("expected at least one fence event, got %d events total with kinds %v", len(events), kinds) + } + if !matchingFence { + t.Fatal("expected Production Area fence event") + } + if !matchingTrackable { + t.Fatal("expected fence event for plugfest-godzilla") + } +} + +func TestPublishFenceEventsForProductionAreaScenario(t *testing.T) { + t.Parallel() + + bus := NewEventBus() + ch, unsubscribe := bus.Subscribe(8) + defer unsubscribe() + + fenceID, err := uuid.Parse("019eab42-3d91-7b8a-8b64-1ca8e2c6055d") + if err != nil { + t.Fatalf("parse fence id failed: %v", err) + } + trackableID, err := uuid.Parse("019eac84-f826-758a-b1f5-7bcb3cfa840a") + if err != nil { + t.Fatalf("parse trackable id failed: %v", err) + } + fence := productionAreaFenceFixture(t, fenceID) + location := testLocationWithCoordinates(t, stringPtrValueRef("EPSG:4326"), "simulated-tag-1", [2]float32{8.90318, 52.01776}) + location.ProviderId = "simulated-tag-1" + location.ProviderType = "virtual" + trackables := []string{trackableID.String()} + location.Trackables = &trackables + + service := &Service{ + bus: bus, + state: NewProcessingState(time.Now), + metadata: &MetadataCache{snapshot: newMetadataSnapshot(nil, []fenceRecord{{Fence: fence, Signature: "fence"}}, nil, nil)}, + crsTransformer: transform.NewCRSTransformer(), + transformCache: transform.NewCache(), + cfg: Config{LocationTTL: time.Minute}, + } + + candidates, err := service.fenceCandidatesForLocation(context.Background(), location) + if err != nil { + t.Fatalf("fenceCandidatesForLocation failed: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("expected one fence candidate, got %d", len(candidates)) + } + point, err := point2D(location.Position) + if err != nil { + t.Fatalf("point2D failed: %v", err) + } + containment, err := fenceContainmentForPoint(fence, point) + if err != nil { + t.Fatalf("fenceContainmentForPoint failed: %v", err) + } + if !containment.Inside { + t.Fatalf("expected test point to be inside Production Area, got %+v", containment) + } + + if err := service.publishFenceEvents(context.Background(), location); err != nil { + t.Fatalf("publishFenceEvents failed: %v", err) + } + + events := collectEvents(ch, 1) + if len(events) != 1 { + t.Fatalf("expected one fence event, got %d", len(events)) + } + if events[0].Kind != EventFenceEvent { + t.Fatalf("expected fence event, got %s", events[0].Kind) + } +} + +func TestFenceContainmentForPointSupportsPointAndPolygon(t *testing.T) { + t.Parallel() + + pointFence := testPointFence(t, uuid.New(), [2]float32{5, 5}, 2) + polygonFence := testPolygonFence(t, uuid.New(), [][2]float32{ + {4, 4}, {6, 4}, {6, 6}, {4, 6}, {4, 4}, + }) + + tests := []struct { + name string + fence gen.Fence + point [2]float64 + inside bool + }{ + {name: "point inside", fence: pointFence, point: [2]float64{5, 5}, inside: true}, + {name: "point outside", fence: pointFence, point: [2]float64{10, 10}, inside: false}, + {name: "polygon inside", fence: polygonFence, point: [2]float64{5, 5}, inside: true}, + {name: "polygon outside", fence: polygonFence, point: [2]float64{10, 10}, inside: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + containment, err := fenceContainmentForPoint(tc.fence, tc.point) + if err != nil { + t.Fatalf("fenceContainmentForPoint failed: %v", err) + } + if containment.Inside != tc.inside { + t.Fatalf("expected inside=%t, got %+v", tc.inside, containment) + } + }) + } +} + func TestProcessDerivedLocationEvaluatesCollisionsWhenPublicationSuppressed(t *testing.T) { t.Parallel() @@ -1495,6 +1721,53 @@ func testPointFence(t *testing.T, id uuid.UUID, coordinates [2]float32, radius f } } +func productionAreaFenceFixture(t *testing.T, id uuid.UUID) gen.Fence { + t.Helper() + polygon := gen.Polygon{ + Type: "Polygon", + Coordinates: [][]gen.GeoJsonPosition{ + { + geoPosition2D(t, 8.903088018151413, 52.01780915621805), + geoPosition2D(t, 8.903091333497857, 52.01771924720516), + geoPosition2D(t, 8.90327833437035, 52.017721858815946), + geoPosition2D(t, 8.903275019023907, 52.01781176782884), + geoPosition2D(t, 8.903088018151413, 52.01780915621805), + }, + }, + } + var region gen.Fence_Region + if err := region.FromPolygon(polygon); err != nil { + t.Fatalf("region setup failed: %v", err) + } + foreignID := "019eab38-b020-7444-894b-4a6333e71b44" + return gen.Fence{ + Id: uuidAsOpenAPI(id), + ForeignId: &foreignID, + Name: stringPtrValueRef("Production Area"), + Region: region, + } +} + +func testPolygonFence(t *testing.T, id uuid.UUID, coordinates [][2]float32) gen.Fence { + t.Helper() + ring := make([]gen.GeoJsonPosition, 0, len(coordinates)) + for _, coordinate := range coordinates { + ring = append(ring, geoPosition2D(t, float64(coordinate[0]), float64(coordinate[1]))) + } + polygon := gen.Polygon{ + Type: "Polygon", + Coordinates: [][]gen.GeoJsonPosition{ring}, + } + var region gen.Fence_Region + if err := region.FromPolygon(polygon); err != nil { + t.Fatalf("region setup failed: %v", err) + } + return gen.Fence{ + Id: uuidAsOpenAPI(id), + Region: region, + } +} + func uuidAsOpenAPI(id uuid.UUID) [16]byte { return [16]byte(id) } @@ -1612,6 +1885,15 @@ func pointAt(t *testing.T, x, y float64) gen.Point { return point } +func geoPosition2D(t *testing.T, x, y float64) gen.GeoJsonPosition { + t.Helper() + var position gen.GeoJsonPosition + if err := position.FromGeoJsonPosition2D([]float32{float32(x), float32(y)}); err != nil { + t.Fatalf("position setup failed: %v", err) + } + return position +} + func zapTestLogger(t *testing.T) *zap.Logger { t.Helper() logger, err := zap.NewDevelopment()