diff --git a/CHANGELOG.md b/CHANGELOG.md index efc96ae..111d5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-06-26 + +### Added + +- `And` aircraft filter combinator (logical AND) — the value form of the + implicit AND that `Filter`/`All` apply to their arguments. Pairs with `Or`/`Not` + for nesting, e.g. `Or(And(a, b), And(c, d))`. + +### Changed + +- Renamed the `Any` filter combinator to `Or`, giving a consistent + `And`/`Or`/`Not` vocabulary. **Breaking:** callers using `Any` must switch to + `Or`. + ## [0.3.2] - 2026-06-26 - `WithinNMOf` aircraft filter for distance from an arbitrary point. @@ -56,7 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the `pkg/client` HTTP client with one-shot queries and channel/iterator polling for every endpoint, the `cmd/wingbits` CLI, and a README. -[Unreleased]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.1...HEAD +[Unreleased]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.4.0...HEAD +[0.4.0]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.2...v0.4.0 +[0.3.2]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.1...v0.3.2 [0.3.1]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.0...v0.3.1 [0.3.0]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.2.1...v0.3.0 [0.2.1]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.2.0...v0.2.1 diff --git a/README.md b/README.md index 0dd86c8..9b8c201 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,15 @@ local, _ := c.Aircraft(ctx, // OR-composition and negation: heavies, _ := c.Aircraft(ctx, - readsb.Any(readsb.WithCategory("A5"), readsb.WithType("mlat")), + readsb.Or(readsb.WithCategory("A5"), readsb.WithType("mlat")), readsb.Not(readsb.OnGround()), ) + +// And is the value form of the implicit AND, for nesting inside Or: +nearby, _ := c.Aircraft(ctx, readsb.Or( + readsb.And(readsb.WithCategory("A7"), readsb.WithinNMOf(40.7, -74.0, 5)), + readsb.And(readsb.WithCategory("A7"), readsb.WithinNMOf(41.9, -72.7, 5)), +)) ``` | Filter Name | Description | Sample Usage | @@ -165,7 +171,8 @@ heavies, _ := c.Aircraft(ctx, | `SeenWithin` | Keeps aircraft heard from within the given duration. | `readsb.SeenWithin(5*time.Minute)` | | `MinRSSI` | Keeps aircraft whose average signal strength is at or above dbfs (e.g. -24). | `readsb.MinRSSI(-24)` | | `Not` | Inverts a filter. | `readsb.Not(readsb.OnGround())` | -| `Any` | Keeps aircraft matching at least one of the supplied filters (logical OR). | `readsb.Any(f1, f2)` | +| `And` | Keeps aircraft matching every supplied filter (logical AND). | `readsb.And(f1, f2)` | +| `Or` | Keeps aircraft matching at least one of the supplied filters (logical OR). | `readsb.Or(f1, f2)` | ### Streaming diff --git a/pkg/types/readsb/filter.go b/pkg/types/readsb/filter.go index 23d3d47..3ffec2c 100644 --- a/pkg/types/readsb/filter.go +++ b/pkg/types/readsb/filter.go @@ -174,13 +174,22 @@ func MinRSSI(dbfs float64) AircraftFilter { return func(a *Aircraft) bool { return a.RSSI >= dbfs } } +// And, Or, and Not compose filters as boolean operators. Filter/All already AND +// their arguments; And is the value form, letting you nest, e.g. +// Or(And(a, b), And(c, d)). + // Not inverts a filter. func Not(f AircraftFilter) AircraftFilter { return func(a *Aircraft) bool { return !f(a) } } -// Any keeps aircraft matching at least one of the supplied filters (logical OR). -func Any(filters ...AircraftFilter) AircraftFilter { +// And keeps aircraft matching every supplied filter (logical AND). +func And(filters ...AircraftFilter) AircraftFilter { + return func(a *Aircraft) bool { return keep(a, filters) } +} + +// Or keeps aircraft matching at least one of the supplied filters (logical OR). +func Or(filters ...AircraftFilter) AircraftFilter { return func(a *Aircraft) bool { for _, f := range filters { if f(a) { diff --git a/pkg/types/readsb/readsb_test.go b/pkg/types/readsb/readsb_test.go index e06494f..c41d55c 100644 --- a/pkg/types/readsb/readsb_test.go +++ b/pkg/types/readsb/readsb_test.go @@ -113,21 +113,34 @@ func TestWithinNMOf(t *testing.T) { } } -func TestAnyOR(t *testing.T) { - // Two non-overlapping 5 nm circles; Any should keep aircraft in either. +func TestOr(t *testing.T) { + // Two non-overlapping 5 nm circles; Or should keep aircraft in either. r := &AircraftReport{Aircraft: []Aircraft{ {Hex: "nearA", Lat: 40.01, Lon: -75.0}, {Hex: "nearB", Lat: 41.0, Lon: -76.01}, {Hex: "between", Lat: 40.5, Lon: -75.5}, {Hex: "nopos"}, }} - near := r.Filter(Any(WithinNMOf(40, -75, 5), WithinNMOf(41, -76, 5))) + near := r.Filter(Or(WithinNMOf(40, -75, 5), WithinNMOf(41, -76, 5))) got := map[string]bool{} for _, a := range near { got[a.Hex] = true } if !got["nearA"] || !got["nearB"] || got["between"] || got["nopos"] || len(got) != 2 { - t.Errorf("Any(WithinNMOf...) OR mismatch, matched: %v", got) + t.Errorf("Or(WithinNMOf...) mismatch, matched: %v", got) + } +} + +func TestAnd(t *testing.T) { + // And matches only aircraft passing every filter; here position AND in-circle. + r := &AircraftReport{Aircraft: []Aircraft{ + {Hex: "in", Lat: 40.01, Lon: -75.0}, + {Hex: "out", Lat: 50.0, Lon: -75.0}, + {Hex: "nopos"}, + }} + near := r.Filter(And(WithPosition(), WithinNMOf(40, -75, 5))) + if len(near) != 1 || near[0].Hex != "in" { + t.Errorf("And mismatch, matched: %+v", near) } }