Skip to content

Commit

Permalink
Add verbose logging for hierarchy resolver operations (#43)
Browse files Browse the repository at this point in the history
* improved logging

* improved logging

* improved logging

* improved docs and verbose logging

* update vendor deps

* docs

* docs

* add hierarchy/README.md

---------

Co-authored-by: sfomuseumbot <sfomuseumbot@localhost>
  • Loading branch information
thisisaaronland and sfomuseumbot authored Jul 19, 2024
1 parent de2c5a2 commit be51d73
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 24 deletions.
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ The goal of the `go-whosonfirst-spatial` package is to de-couple the various com

It is the "base" package that defines provider-agnostic, but WOF-specific, interfaces for a limited set of spatial queries and reading properties.

These interfaces are then implemented in full or in part by provider-specific classes. For example, an in-memory RTree index or a SQLite database or even a Protomaps database:
These interfaces are then implemented in full or in part by provider-specific classes. For example, an in-memory RTree index (which is part of this package) or a SQLite database or even a Protomaps database:

* https://github.com/whosonfirst/go-whosonfirst-spatial-rtree
* https://github.com/whosonfirst/go-whosonfirst-spatial-sqlite
* https://github.com/whosonfirst/go-whosonfirst-spatial-pmtiles

Expand Down Expand Up @@ -97,31 +96,31 @@ type Filter interface {

_Where `flags.*` refers to the [whosonfirst/go-whosonfirst-flags](https://github.com/whosonfirst/go-whosonfirst-flags) package._

## Implementations
## Database Implementations

### SQLite

* https://github.com/whosonfirst/go-whosonfirst-spatial-rtree
* https://github.com/whosonfirst/go-whosonfirst-spatial-sqlite

### PMTiles

* https://github.com/whosonfirst/go-whosonfirst-spatial-pmtiles

## Servers and clients

### WWW
### Web (HTTP)

* https://github.com/whosonfirst/go-whosonfirst-spatial-www
* https://github.com/whosonfirst/go-whosonfirst-spatial-www-sqlite
* https://github.com/whosonfirst/go-whosonfirst-spatial-www-pmtiles

_Remember, this package implements the guts of a web application but does not support any particular database by default. It is meant to be imported by a database-specific implementation (see above) and exposed as a `cmd/server` application (for example) by that package._

### gRPC

* https://github.com/whosonfirst/go-whosonfirst-spatial-grpc
* https://github.com/whosonfirst/go-whosonfirst-spatial-grpc-sqlite
* https://github.com/whosonfirst/go-whosonfirst-spatial-grpc-pmtiles

## Services and Operations

* https://github.com/whosonfirst/go-whosonfirst-spatial-pip
* https://github.com/whosonfirst/go-whosonfirst-spatial-hierarchy

_Note, the gRPC code has not been updated in a while and needs to be refactored to follow the model of the `go-whosonfirst-spatial-www` pacakge._
## See also

* https://github.com/whosonfirst/go-whosonfirst-spr
Expand Down
2 changes: 2 additions & 0 deletions filter/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ func FilterSPR(filters spatial.Filter, s spr.StandardPlacesResult) error {

var ok bool

slog.Debug("Create placetype flag for SPR filtering", "placetype", s.Placetype())

pf, err := placetypes.NewPlacetypeFlag(s.Placetype())

if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ require (
github.com/whosonfirst/go-sanitize v0.1.0
github.com/whosonfirst/go-whosonfirst-export/v2 v2.7.1
github.com/whosonfirst/go-whosonfirst-feature v0.0.27
github.com/whosonfirst/go-whosonfirst-flags v0.5.1
github.com/whosonfirst/go-whosonfirst-flags v0.5.2
github.com/whosonfirst/go-whosonfirst-iterate/v2 v2.4.1
github.com/whosonfirst/go-whosonfirst-placetypes v0.7.2
github.com/whosonfirst/go-whosonfirst-reader v1.0.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ github.com/whosonfirst/go-whosonfirst-export/v2 v2.7.1 h1:XM/TKVfj4Pknc6QgNgFNhw
github.com/whosonfirst/go-whosonfirst-export/v2 v2.7.1/go.mod h1:RaY70vL/lqS2osrECto6rwb1CIeynJgBIo7yf1um3+E=
github.com/whosonfirst/go-whosonfirst-feature v0.0.27 h1:8RoiadvQEo8RFq8HFezq/Mwm/7UXR+dNJpE9oP8kvfQ=
github.com/whosonfirst/go-whosonfirst-feature v0.0.27/go.mod h1:vAtQysyMODE/ynMxSrHJ2eCBJRNFj9xUszrURnOy9Xc=
github.com/whosonfirst/go-whosonfirst-flags v0.5.1 h1:kRzXK7WZlEK1hNw+CECEdnWNtEDbWbjWdEg0imu1mGE=
github.com/whosonfirst/go-whosonfirst-flags v0.5.1/go.mod h1:VgXcWNtsCZGy/Xnt9bpSUTKJ3nYeqXqvLD3NrE6kzZg=
github.com/whosonfirst/go-whosonfirst-flags v0.5.2 h1:vSx7fcM04HiSAEF+WOKfuyJawac99skN9HJAeqBXWpI=
github.com/whosonfirst/go-whosonfirst-flags v0.5.2/go.mod h1:kozBozsyI8cKekUces1m10vJ301sH2EqLZtjX1xCSqQ=
github.com/whosonfirst/go-whosonfirst-format v0.4.1 h1:TVSrbbB/sXhAgyaTenaS93lERRITGQHBKIDwoEgxoqA=
github.com/whosonfirst/go-whosonfirst-format v0.4.1/go.mod h1:lMBIXCnD9ZA+wCNtu6XFYoq5DTfnEALO8k6OkEn11MM=
github.com/whosonfirst/go-whosonfirst-id v1.2.2 h1:pyfwE26+W+A9qg11WlzBBsviBr7NhhJrbvhPWyf/2EE=
Expand Down
166 changes: 166 additions & 0 deletions hierarchy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Point-in-polygon "hierarchy resolvers"

At a high-level a point-in-polygon "hierarchy resolver" consists of (4) parts:

* Given a GeoJSON Feature use its geometry to derive the most appropriate centroid for performing a point-in-polygon query
* Perform a point-in-polygon query for a centroid, excluding results using criteria defined by zero or more filters.
* Convert the list of candidate results (derived from the point-in-polygon query) in to a single result using a callback function.
* Apply updates derived from the final result to the original GeoJSON Feature using a callback function.

These functionalities are implemented by the `hierarchy.PointInPolygonHierarchyResolver` package. In addition to wrapping all those moving pieces the `hierachy` package also exports a handful of predefined callback functions to use for filtering results and applying updates.

## Example

The following examples describe how to use the `hierarchy.PointInPolygonHierarchyResolver` package in abbreviated (incomplete) and annotated code.

_Note: For the sake of brevity all error-handling has been removed from these examples._

### Basic

This example demonstrates how to use the `hierarchy.PointInPolygonHierarchyResolver` package with a set of "core" Who's On First documents.

```
import (
"context"
"github.com/sfomuseum/go-sfomuseum-mapshaper"
"github.com/whosonfirst/go-whosonfirst-spatial/database"
_ "github.com/whosonfirst/go-whosonfirst-spatial-sqlite"
"github.com/whosonfirst/go-whosonfirst-spatial/filter"
"github.com/whosonfirst/go-whosonfirst-spatial/hierarchy"
hierarchy_filter "github.com/whosonfirst/go-whosonfirst-spatial/hierarchy/filter"
)
func main() {
ctx := context.Background()
// The Mapshaper "client" (and its associated "server") is not required by a point-in-polygon
// hierarchy resolver but is included in this example for the sake of thoroughness. If present
// it will be used to derive the centroid for a GeoJSON Feature using the Mapshape "inner point"
// command. Both the "client" and "server" components are part of the [sfomuseum/go-sfomuseum-mapshaper](#)
// package but setting up and running the "server" component is out of scope for this document.
// Basically Mapshaper's "inner point" functonality can't be ported to Go fast enough.
//
// If the mapshaper client is `nil` then there are a variety of other heuristics that will be
// used, based on the content of the input GeoJSON Feature, to derive a candidate centroid to
// be used for point-in-polygon operations.
mapshaper_cl, _ := mapshaper.NewClient(ctx, "http://localhost:8080")
// Create a new spatial database instance. For the sake of this example it
// is assumed that the database has already been populated with records.
spatial_db, _ := database.NewSpatialDatabase(ctx, "sqlite://?dsn=modernc://cwd/example.db")
// Create configuration options for hierarchy resolver
resolver_opts := &hierarchy.PointInPolygonHierarchyResolverOptions{
Database: spatial_db,
Mapshaper: mapshaper_cl,
}
// Create the hierarchy resolver itself
resolver, _ := hierarchy.NewPointInPolygonHierarchyResolver(ctx, resolver_opts)
// Create zero or more filters to prune point-in-polygon results with, in this case
// only return records whose `mz:is_current` property is "1".
pip_inputs := &filter.SPRInputs{
IsCurrent: []int64{1},
}
// Instantiate a predefined results callback function that returns the first result in a list
// of candidates but does not trigger an error if that list is empty.
results_cb := hierarchy_filter.FirstButForgivingSPRResultsFunc
// Instantiate a predefined update callback that will return a dictionary populated with the
// following properties from the final point-in-polygon result (derived from `results_cb`):
// wof:parent_id, wof:hierarchy, wof:country
update_cb := hierarchy.DefaultPointInPolygonHierarchyResolverUpdateCallback()
// Where body is assumed to be a valid Who's On First style GeoJSON Feature
var body []byte
// Invoke the hierarchy resolver's `PointInPolygonAndUpdate` method using `body` as the input
// parameter.
updates, _ := resolver.PointInPolygonAndUpdate(ctx, pip_inputs, results_cb, update_cb, body)
// Apply updates to body here
}
```

### Custom placetypes

This example demonstrates how to the `hierarchy.PointInPolygonHierarchyResolver` package with a set of Who's On First style documents that contain custom placetypes (defined in a separate property from the default `wof:placetype` property).

```
import (
"context"
"github.com/sfomuseum/go-sfomuseum-mapshaper"
_ "github.com/sfomuseum/go-sfomuseum-placetypes"
"github.com/whosonfirst/go-whosonfirst-placetypes"
"github.com/whosonfirst/go-whosonfirst-spatial/database"
_ "github.com/whosonfirst/go-whosonfirst-spatial-sqlite"
"github.com/whosonfirst/go-whosonfirst-spatial/filter"
"github.com/whosonfirst/go-whosonfirst-spatial/hierarchy"
hierarchy_filter "github.com/whosonfirst/go-whosonfirst-spatial/hierarchy/filter"
)
func main() {
mapshaper_cl, _ := mapshaper.NewClient(ctx, "http://localhost:8080")
spatial_db, _ := database.NewSpatialDatabase(ctx, "sqlite://?dsn=modernc://cwd/example.db")
// Create a new custom placetypes definition. In this case the standard Who's On First places
// definition supplemented with custom placetypes used by SFO Museum. This is used to derive
// the list of (custom) ancestors associated with any given (custom) placetype.
pt_def, _ := placetypes.NewDefinition(ctx, "sfomuseum://")
// Append the custom placetypes definition to the hierarchy resolver options AND explicitly
// disable placetype filtering (removing candidates that are not ancestors of the placetype
// of the Who's On First GeoJSON Feature being PIP-ed.
//
// If you don't disable default placetype filtering you will need to ensure that the `wof:placetype`
// property of the features in the spatial database are manually reassigned to take the form
// of "PLACETYPE" + "#" + "PLACETYPE_DEFINITION_URI", for example: "airport#sfomuseum://"
//
// More accurately though the requirement is less that you need to alter the values in the underlying
// database so much as ensure that the value returned by the `Placetype` method of each `StandardPlacesResult`
// (SPR) candidate result produced during a point-in-polygon operation is formatted that way. There is
// more than one way to do this but as a practical matter it's probably easiest to store that (formatted)
// value in the database IF the database itself is transient. There can be no guarantees thought that
// a change like this won't have downstream effects on the rest of your code.
//
// If you're curious the "PLACETYPE" + "#" + "PLACETYPE_DEFINITION_URI" syntax is parsed by the
// code used to create placetype filter flags in the `whosonfirst/go-whosonfirst-flags` package and
// used to load custom definitions on the fly to satisfy tests.
//
// Basically, custom placetypes make things more complicated because they are... well, custom. At a
// certain point it may simply be easier to disable default placetype checks in your own custom results
// filtering callback function.
resolver_opts := &hierarchy.PointInPolygonHierarchyResolverOptions{
Database: spatial_db,
Mapshaper: mapshaper_cl,
PlacetypesDefinition: pt_def,
SkipPlacetypeFilter: true,
}
resolver, _ := hierarchy.NewPointInPolygonHierarchyResolver(ctx, resolver_opts)
pip_inputs := &filter.SPRInputs{
IsCurrent: []int64{1},
}
// In the case of SFO Museum related records here is a custom results callback that implements its
// own placetype and floor level checking.
results_cb := sfom_hierarchy.ChoosePointInPolygonCandidateStrict
update_cb := hierarchy.DefaultPointInPolygonHierarchyResolverUpdateCallback()
var body []byte
updates, _ := resolver.PointInPolygonAndUpdate(ctx, pip_inputs, results_cb, update_cb, body)
// Apply updates to body here
}
```
36 changes: 32 additions & 4 deletions hierarchy/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ type PointInPolygonHierarchyResolverOptions struct {
// PlacetypesDefinition is an optional `go-whosonfirst-placetypes.Definition` instance used to resolve custom or bespoke placetypes.
PlacetypesDefinition placetypes.Definition
// SkipPlacetypeFilter is an optional boolean flag to signal whether or not point-in-polygon operations should be performed using
// the list of known ancestors for a given placetype. Default is false.
// the list of known ancestors for a given placetype. If you are using a custom placetypes defintion (see whosonfirst/go-whosonfirst-placetypes)
// and do not enable this flag you will need to manually re-assign the `wof:placetype` property of each record being ingested in to your spatial
// database to take the form of "{CUSTOM_PLACETYPE}#{CUSTOM_PLACETYPE_DEFINITION_URI}". This is necessary because by the time placetype filtering
// occurs the code is working with `whosonfirst/go-whosonfirst-spr.StandardPlacesResult` instances which only have access to a generic `Placetype`
// method. There is no guarantee that changing the default value of the `wof:placetype` property will not have unintended consequences so it might
// be easiest just to enable this flag and deal with placetype filtering in a custom `FilterSPRResultsFunc` callback. Default is false.
SkipPlacetypeFilter bool
// Roles is an optional list of Who's On First placetype roles used to derive ancestors during point-in-polygon operations.
// If missing (or zero length) then all possible roles will be assumed.
Expand Down Expand Up @@ -139,6 +144,15 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygonAndUpdate(ctx context.Co
// from if `wof:placetype=custom`.
func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, inputs *filter.SPRInputs, body []byte) ([]spr.StandardPlacesResult, error) {

id_rsp := gjson.GetBytes(body, "properties.wof:id")
name_rsp := gjson.GetBytes(body, "properties.wof:name")
id := id_rsp.String()
name := name_rsp.String()

logger := slog.Default()
logger = logger.With("id", id)
logger = logger.With("name", name)

centroid, err := t.PointInPolygonCentroid(ctx, body)

if err != nil {
Expand All @@ -148,6 +162,9 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, in
lon := centroid.X()
lat := centroid.Y()

logger = logger.With("latitude", lat)
logger = logger.With("longitude", lon)

coord, err := geo.NewCoordinate(lon, lat)

if err != nil {
Expand All @@ -162,7 +179,7 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, in
return nil, fmt.Errorf("Failed to create SPR filter from input, %v", err)
}

slog.Debug("Perform point in polygon with no placetype filter", "lat", lat, "lon", lon)
logger.Debug("Perform point in polygon with no placetype filter")

rsp, err := t.Database.PointInPolygon(ctx, coord, spr_filter)

Expand All @@ -173,10 +190,13 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, in
// This should never happen...

if rsp == nil {
logger.Warn("Failed to point in polygon with empty response, returning nil")
return nil, fmt.Errorf("Failed to point in polygon for %v, null response", coord)
}

possible := rsp.Results()

logger.Debug("Return unfiltered-by-placetype results", "count", len(possible))
return possible, nil
}

Expand Down Expand Up @@ -205,8 +225,12 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, in
return nil, fmt.Errorf("Failed to create new placetype for '%s', %v", pt_str, err)
}

logger = logger.With("placetype", pt_str)

ancestors := pt_spec.AncestorsForRoles(pt, t.roles)

// logger.Debug("Ancestors", "roles", t.roles, "ancestors", ancestors)

for _, a := range ancestors {

pt_name := fmt.Sprintf("%s#%s", a.Name, pt_uri)
Expand All @@ -219,7 +243,7 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, in
return nil, fmt.Errorf("Failed to create SPR filter from input, %v", err)
}

slog.Debug("Perform point in polygon", "lat", lat, "lon", lon, "placetype", pt_name)
logger.Debug("Perform point in polygon with placetype filter", "placetype", pt_name)

rsp, err := t.Database.PointInPolygon(ctx, coord, spr_filter)

Expand All @@ -236,16 +260,20 @@ func (t *PointInPolygonHierarchyResolver) PointInPolygon(ctx context.Context, in
results := rsp.Results()
count := len(results)

slog.Debug("Point in polygon results", "lat", lat, "lon", lon, "placetype", pt_name, "count", count)
logger.Debug("Point in polygon results after input filtering", "placetype", pt_name, "count", count)

if count == 0 {
continue
}

possible = results

// Something something something filter here something something something

break
}

logger.Debug("Return possible candidates", "count", len(possible))
return possible, nil
}

Expand Down
Loading

0 comments on commit be51d73

Please sign in to comment.