package geocode import ( "context" "fmt" "strings" "time" "github.com/Gleipnir-Technology/bob" "github.com/Gleipnir-Technology/bob/dialect/psql" "github.com/Gleipnir-Technology/bob/dialect/psql/im" "github.com/Gleipnir-Technology/bob/dialect/psql/sm" "github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/h3utils" "github.com/Gleipnir-Technology/nidus-sync/platform/types" "github.com/Gleipnir-Technology/nidus-sync/stadia" "github.com/stephenafamo/scan" //"github.com/rs/zerolog/log" "github.com/uber/h3-go/v4" ) type GeocodeResult struct { Address types.Address Cell h3.Cell Location types.Location } var client *stadia.StadiaMaps func InitializeStadia(key string) { client = stadia.NewStadiaMaps(key) } // Ensure the provided address exists. If it doesn't add it to the database. func EnsureAddress(ctx context.Context, txn bob.Tx, a types.Address, l types.Location) (*models.Address, error) { address, err := models.Addresses.Query( models.SelectWhere.Addresses.Country.EQ(a.CountryEnum()), models.SelectWhere.Addresses.Locality.EQ(a.Locality), models.SelectWhere.Addresses.Number.EQ(a.Number), models.SelectWhere.Addresses.PostalCode.EQ(a.PostalCode), models.SelectWhere.Addresses.Region.EQ(a.Region), models.SelectWhere.Addresses.Street.EQ(a.Street), models.SelectWhere.Addresses.Unit.EQ(a.Unit), ).One(ctx, txn) if err == nil { return address, nil } cell, err := h3utils.GetCell(l.Longitude, l.Latitude, 15) if err != nil { return nil, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", l.Longitude, l.Latitude) } type _row struct { ID int32 `db:"id"` } created := time.Now() row, err := bob.One(ctx, txn, psql.Insert( im.Into("address", "country", "created", "h3cell", "id", "locality", "location", "number_", "postal_code", "region", "street", "unit"), im.Values( psql.Arg(a.CountryEnum()), psql.Arg(created), psql.Arg(cell), psql.Raw("DEFAULT"), psql.Arg(a.Locality), psql.F("ST_Point", l.Longitude, l.Latitude, 4326), psql.Arg(a.Number), psql.Arg(a.PostalCode), psql.Arg(a.Region), psql.Arg(a.Street), psql.Raw("''"), ), im.Returning("id"), ), scan.StructMapper[_row]()) if err != nil { return nil, fmt.Errorf("insert: %w", err) } return &models.Address{ Country: a.CountryEnum(), Created: created, H3cell: "", ID: row.ID, Locality: a.Locality, Location: "", PostalCode: a.PostalCode, Street: a.Street, Unit: a.Unit, Region: a.Region, Number: a.Number, }, nil } // Either get an address that matches, or create a new address. Either way, return an address // This will make a call to a structured geocode service, so it's slow. func EnsureAddressWithGeocode(ctx context.Context, txn bob.Tx, org *models.Organization, a types.Address) (*models.Address, error) { address, err := models.Addresses.Query( models.SelectWhere.Addresses.Country.EQ(a.CountryEnum()), models.SelectWhere.Addresses.Locality.EQ(a.Locality), models.SelectWhere.Addresses.Number.EQ(a.Number), models.SelectWhere.Addresses.PostalCode.EQ(a.PostalCode), models.SelectWhere.Addresses.Region.EQ(a.Region), models.SelectWhere.Addresses.Street.EQ(a.Street), models.SelectWhere.Addresses.Unit.EQ(a.Unit), ).One(ctx, txn) if err == nil { return address, nil } // Geocode geo, err := GeocodeStructured(ctx, org, a) if err != nil { return nil, fmt.Errorf("geocode: %w", err) } type _row struct { ID int32 `db:"id"` } created := time.Now() row, err := bob.One(ctx, txn, psql.Insert( im.Into("address", "country", "created", "h3cell", "id", "locality", "location", "number_", "postal_code", "region", "street", "unit"), im.Values( psql.Arg(geo.Address.Country), psql.Arg(created), psql.Arg(geo.Cell), psql.Raw("DEFAULT"), psql.Arg(geo.Address.Locality), psql.F("ST_Point", geo.Location.Longitude, geo.Location.Latitude, 4326), psql.Arg(geo.Address.Number), psql.Arg(geo.Address.PostalCode), psql.Arg(geo.Address.Region), psql.Arg(geo.Address.Street), psql.Raw("''"), ), im.Returning("id"), ), scan.StructMapper[_row]()) if err != nil { return nil, fmt.Errorf("insert: %w", err) } return &models.Address{ Country: geo.Address.CountryEnum(), Created: created, H3cell: "", ID: row.ID, Locality: geo.Address.Locality, Location: "", PostalCode: geo.Address.PostalCode, Street: geo.Address.Street, Unit: geo.Address.Unit, Region: geo.Address.Region, Number: geo.Address.Number, }, nil } func GeocodeRaw(ctx context.Context, org *models.Organization, address string) (*GeocodeResult, error) { req := stadia.RequestGeocodeRaw{ Text: address, } maybeAddServiceArea(&req, org) resp, err := client.GeocodeRaw(ctx, req) if err != nil { return nil, fmt.Errorf("client raw geocode failure on %s: %w", address, err) } return toGeocodeResult(*resp, address) } func GeocodeStructured(ctx context.Context, org *models.Organization, a types.Address) (*GeocodeResult, error) { street := fmt.Sprintf("%s %s", a.Number, a.Street) req := stadia.RequestGeocodeStructured{ Address: &street, //Country: &a.Country, Locality: &a.Locality, PostalCode: &a.PostalCode, Region: &a.Region, } maybeAddServiceArea(&req, org) resp, err := client.GeocodeStructured(ctx, req) if err != nil { return nil, fmt.Errorf("client structured geocode failure on %s: %w", a.String(), err) } return toGeocodeResult(*resp, a.String()) } func ReverseGeocode(ctx context.Context, location types.Location) (*GeocodeResult, error) { req := stadia.RequestReverseGeocode{ Latitude: location.Latitude, Longitude: location.Longitude, } resp, err := client.ReverseGeocode(ctx, req) if err != nil { return nil, fmt.Errorf("client reverse geocode failure on %s: %w", location.String(), err) } return toGeocodeResult(*resp, location.String()) } func toGeocodeResult(resp stadia.GeocodeResponse, address_msg string) (*GeocodeResult, error) { if len(resp.Features) < 1 { return nil, fmt.Errorf("%s matched no locations", address_msg) } feature := resp.Features[0] if len(resp.Features) > 1 { if !allFeaturesIdenticalEnough(resp.Features) { return nil, fmt.Errorf("%s matched more than one location, and they differ a lot", address_msg) } } if feature.Geometry.Type != "Point" { return nil, fmt.Errorf("wrong type %s from %s", feature.Geometry.Type, address_msg) } longitude := feature.Geometry.Coordinates[0] latitude := feature.Geometry.Coordinates[1] cell, err := h3utils.GetCell(longitude, latitude, 15) if err != nil { return nil, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", longitude, latitude) } country_s := strings.ToLower(feature.Properties.CountryA) // Depending on what kind of request we made we'll get wildly different result structures // This first structure generally works for forword geocoding address := types.Address{ Country: country_s, Locality: feature.Properties.Locality, Number: feature.Properties.HouseNumber, PostalCode: feature.Properties.PostalCode, Region: feature.Properties.Region, Street: feature.Properties.Street, Unit: "", } // If we don't have a locality, try populating for reverse geocoding if address.Country == "" { address.Country = strings.ToLower(feature.Properties.Context.ISO3166A3) address.Locality = feature.Properties.Context.WhosOnFirst.Locality.Name address.Number = feature.Properties.AddressComponents.Number address.PostalCode = feature.Properties.AddressComponents.PostalCode address.Street = feature.Properties.AddressComponents.Street } return &GeocodeResult{ Address: address, Cell: cell, Location: types.Location{ Longitude: feature.Geometry.Coordinates[0], Latitude: feature.Geometry.Coordinates[1], }, }, nil } // Get the parcel for a given address, if one can be found func GetParcel(ctx context.Context, txn bob.Tx, a *models.Address) (*models.Parcel, error) { result, err := models.Parcels.Query( sm.InnerJoin("address").On(psql.F("ST_Contains", psql.Raw("parcel.geometry"), psql.Raw("address.location"))), models.SelectWhere.Addresses.ID.EQ(a.ID), ).One(ctx, txn) if err != nil { if err.Error() == "sql: no rows in result set" { return nil, nil } return nil, fmt.Errorf("Get parcel from address %d: %w", a.ID, err) } return result, nil } func allFeaturesIdenticalEnough(features []stadia.GeocodeFeature) bool { if len(features) < 2 { return true } f := features[0].Properties for _, feature := range features { if feature.Properties.CountryCode != f.CountryCode || feature.Properties.County != f.County || feature.Properties.HouseNumber != f.HouseNumber || feature.Properties.Locality != f.Locality || feature.Properties.RegionA != f.RegionA { return false } } return true } func maybeAddServiceArea(req stadia.RequestGeocode, org *models.Organization) { if org.ServiceAreaXmax.IsNull() || org.ServiceAreaYmax.IsNull() || org.ServiceAreaXmin.IsNull() || org.ServiceAreaYmin.IsNull() { return } xmax := org.ServiceAreaXmax.MustGet() ymax := org.ServiceAreaYmax.MustGet() xmin := org.ServiceAreaXmin.MustGet() ymin := org.ServiceAreaYmin.MustGet() req.SetBoundaryRect(xmin, ymin, xmax, ymax) if org.ServiceAreaCentroidX.IsNull() || org.ServiceAreaCentroidY.IsNull() { return } centroid_x := org.ServiceAreaCentroidX.MustGet() centroid_y := org.ServiceAreaCentroidY.MustGet() req.SetFocusPoint(centroid_x, centroid_y) }