Skip to content

Commit 98ac4ac

Browse files
authored
Merge pull request #137 from DataIntegrationGroup/jab-location-transfer-updates
BDMS 111: location updates & other
2 parents 54772a3 + 96dbff8 commit 98ac4ac

20 files changed

Lines changed: 474 additions & 210 deletions

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name: Tests
55

66
on:
77
pull_request:
8-
branches: [ "main",'pre-production', 'transfer']
8+
branches: ['production', 'staging', 'transfer']
99

1010
permissions:
1111
contents: read

api/geospatial.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# limitations under the License.
1515
# ===============================================================================
1616
import json
17-
from typing import Annotated, List, Union
17+
from typing import Annotated, List
1818

1919
from fastapi import APIRouter, Query, HTTPException
2020
from fastapi.responses import FileResponse
@@ -100,7 +100,9 @@ def get_feature_collection(
100100

101101
things = get_thing_features(session, thing_type, group)
102102

103-
def make_feature_dict(thing, geometry, *other):
103+
def make_feature_dict(thing, geometry, elevation, *other):
104+
geometry = json.loads(geometry)
105+
geometry["coordinates"].append(elevation)
104106
return {
105107
"type": "Feature",
106108
"properties": {
@@ -109,7 +111,7 @@ def make_feature_dict(thing, geometry, *other):
109111
"name": thing.name,
110112
"group": group,
111113
},
112-
"geometry": json.loads(geometry),
114+
"geometry": geometry,
113115
}
114116

115117
features = [make_feature_dict(*item) for item in things]

api/location.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from services.geospatial_helper import make_within_wkt
3131
from services.query_helper import make_query, order_sort_filter, simple_get_by_id
3232
from services.crud_helper import model_patcher, model_deleter, model_adder
33+
from services.location_helper import set_geographic_attributes
3334

3435
from fastapi import APIRouter
3536

@@ -48,7 +49,9 @@ async def create_location(
4849
"""
4950
Create a new sample location in the database.
5051
"""
51-
return model_adder(session, Location, location_data, user=user)
52+
location = model_adder(session, Location, location_data, user=user)
53+
set_geographic_attributes(session, location_data, location)
54+
return location
5255

5356

5457
@router.patch(
@@ -64,7 +67,9 @@ async def update_location(
6467
"""
6568
Update a sample location in the database.
6669
"""
67-
return model_patcher(session, Location, location_id, location_data, user=user)
70+
location = model_patcher(session, Location, location_id, location_data, user=user)
71+
set_geographic_attributes(session, location_data, location)
72+
return location
6873

6974

7075
# @router.get("/shapefile", summary="Get location as shapefile")

constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@
1515
# ===============================================================================
1616

1717
SRID_WGS84 = 4326
18+
SRID_UTM_ZONE_13N = 26913
1819
# ============= EOF =============================================

db/group.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from sqlalchemy.orm import relationship, Mapped
2121
from sqlalchemy.testing.schema import mapped_column
2222

23+
from constants import SRID_WGS84
2324
from db.base import Base, AutoBaseMixin, ReleaseMixin
2425

2526

@@ -28,7 +29,7 @@ class Group(Base, AutoBaseMixin, ReleaseMixin):
2829
description: Mapped[str] = mapped_column(String(255), nullable=True)
2930
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
3031
project_area: Mapped[Optional[WKBElement]] = mapped_column(
31-
Geometry(geometry_type="MULTIPOLYGON", srid=4326, spatial_index=True)
32+
Geometry(geometry_type="MULTIPOLYGON", srid=SRID_WGS84, spatial_index=True)
3233
)
3334

3435
# Foreign Keys

db/location.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
from uuid import UUID
2222

2323
from sqlalchemy import (
24-
Column,
25-
Integer,
2624
String,
2725
ForeignKey,
2826
DateTime,
@@ -32,6 +30,7 @@
3230
from sqlalchemy.orm import relationship, Mapped, mapped_column
3331
from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy
3432

33+
from constants import SRID_WGS84
3534
from db.base import Base, AutoBaseMixin, ReleaseMixin
3635
from db.lexicon import lexicon_term
3736

@@ -43,9 +42,12 @@ class Location(Base, AutoBaseMixin, ReleaseMixin):
4342
String(36), nullable=True, unique=True
4443
)
4544
description: Mapped[str] = mapped_column
46-
name: Mapped[str] = mapped_column(String(255), nullable=True)
45+
# name: Mapped[str] = mapped_column(String(255), nullable=True)
4746
point: Mapped[WKBElement] = mapped_column(
48-
Geometry(geometry_type="POINTZ", srid=4326, spatial_index=True)
47+
Geometry(geometry_type="POINT", srid=SRID_WGS84, spatial_index=True)
48+
)
49+
elevation: Mapped[float] = mapped_column(
50+
nullable=False, comment="in meters with vertical datum of NAVD88"
4951
)
5052

5153
state: Mapped[str] = lexicon_term(nullable=True, default="New Mexico")
@@ -65,7 +67,7 @@ class Location(Base, AutoBaseMixin, ReleaseMixin):
6567
)
6668

6769
# --- Proxy Definitions ---
68-
things: AssociationProxy[list["Thing"]] = association_proxy(
70+
things: AssociationProxy[list["Thing"]] = association_proxy( # noqa: F821
6971
"thing_associations", "thing"
7072
)
7173

@@ -95,7 +97,9 @@ class LocationThingAssociation(Base, AutoBaseMixin):
9597

9698
# --- Relationship Definitions ---
9799
location: Mapped["Location"] = relationship(back_populates="thing_associations")
98-
thing: Mapped["Thing"] = relationship(back_populates="location_associations")
100+
thing: Mapped["Thing"] = relationship( # noqa: F821
101+
back_populates="location_associations"
102+
)
99103

100104

101105
# ============= EOF =============================================

schemas/location.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ class CreateLocation(BaseCreateModel):
3434
Schema for creating a sample location.
3535
"""
3636

37-
name: str | None = None
37+
# name: str | None = None
3838
notes: str | None = None
3939
point: str # point is required and should be in WKT format
40+
elevation: float
4041
release_status: str | None = "draft"
4142
elevation_accuracy: float | None = None
4243
elevation_method: str | None = None
@@ -64,9 +65,12 @@ class LocationResponse(BaseResponseModel):
6465
Response schema for sample location details.
6566
"""
6667

67-
name: str | None
68+
# name: str | None
6869
notes: str | None
6970
point: str
71+
elevation: float | None
72+
horizontal_datum: str = "WGS84"
73+
vertical_daum: str = "NAVD88"
7074
release_status: str | None
7175
elevation_accuracy: float | None
7276
elevation_method: str | None
@@ -103,9 +107,10 @@ class UpdateLocation(BaseUpdateModel):
103107
Schema for updating a location.
104108
"""
105109

106-
name: str | None = None
110+
# name: str | None = None
107111
notes: str | None = None
108112
point: str | None = None
113+
elevation: float | None = None
109114
release_status: str | None = None
110115
elevation_accuracy: float | None = None
111116
elevation_method: str | None = None

services/geospatial_helper.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,8 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
# ===============================================================================
16-
import json
17-
1816
import shapefile
1917
from shapely.errors import GEOSException
20-
from geoalchemy2 import functions as geofunc
2118
from shapely.io import from_geojson
2219

2320
import constants
@@ -46,7 +43,7 @@ def get_thing_features(
4643
# selection_args.append(SpringThing)
4744

4845
sql = (
49-
select(Thing, ST_AsGeoJSON(Location.point).label("geojson"))
46+
select(Thing, ST_AsGeoJSON(Location.point).label("geojson"), Location.elevation)
5047
.join(LocationThingAssociation, Thing.id == LocationThingAssociation.thing_id)
5148
.join(Location, LocationThingAssociation.location_id == Location.id)
5249
)
@@ -77,7 +74,7 @@ def create_shapefile(things: list, filename: str = "things.shp") -> None:
7774
shp.field("id", "L")
7875
shp.field("name", "C")
7976

80-
for thing, point in things:
77+
for thing, point, elevation in things:
8178
# Assume loc.point is WKT or a Shapely geometry or GeoJSON
8279
if isinstance(point, str):
8380
try:
@@ -88,7 +85,7 @@ def create_shapefile(things: list, filename: str = "things.shp") -> None:
8885
geom = to_shape(point)
8986

9087
shp.point(geom.x, geom.y)
91-
shp.record(thing.id, thing.name)
88+
shp.record(thing.id, thing.name, elevation)
9289

9390

9491
def make_within_wkt(sql: Select, wkt: str) -> Select:

services/location_helper.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from shapely.wkt import loads
2+
from pydantic import BaseModel
3+
from sqlalchemy.orm import Session
4+
5+
from db.location import Location
6+
from services.util import (
7+
get_state_from_point,
8+
get_county_from_point,
9+
get_quad_name_from_point,
10+
)
11+
12+
13+
def set_geographic_attributes(
14+
session: Session, payload: BaseModel, location: Location
15+
) -> None:
16+
"""
17+
Set geographic attributes for a location based off of the point. This function
18+
is to be used for both POST and PATCH requests.
19+
"""
20+
if payload.point is not None:
21+
point = loads(payload.point)
22+
longitude = point.x
23+
latitude = point.y
24+
location.state = get_state_from_point(longitude, latitude)
25+
location.county = get_county_from_point(longitude, latitude)
26+
location.quad_name = get_quad_name_from_point(longitude, latitude)
27+
session.commit()

services/util.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from shapely.ops import transform
2+
import pyproj
3+
import httpx
4+
5+
from constants import SRID_WGS84
6+
7+
TRANSFORMERS = {}
8+
9+
10+
def transform_srid(geometry, source_srid, target_srid):
11+
"""
12+
geometry must be a shapely geometry object, like Point, Polygon, or MultiPolygon
13+
"""
14+
transformer_key = (source_srid, target_srid)
15+
if transformer_key not in TRANSFORMERS:
16+
source_crs = pyproj.CRS(f"EPSG:{source_srid}")
17+
target_crs = pyproj.CRS(f"EPSG:{target_srid}")
18+
transformer = pyproj.Transformer.from_crs(
19+
source_crs, target_crs, always_xy=True
20+
)
21+
TRANSFORMERS[transformer_key] = transformer
22+
else:
23+
transformer = TRANSFORMERS[transformer_key]
24+
return transform(transformer.transform, geometry)
25+
26+
27+
def get_tiger_data(
28+
lon: float, lat: float, layer: int, outfields: str = "*"
29+
) -> dict | None:
30+
url = f"https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/State_County/MapServer/{layer}/query"
31+
params = {
32+
"f": "json",
33+
"where": "1=1",
34+
"geometry": f"{lon},{lat}",
35+
"geometryType": "esriGeometryPoint",
36+
"inSR": f"{SRID_WGS84}",
37+
"spatialRel": "esriSpatialRelIntersects",
38+
"outFields": outfields,
39+
"returnGeometry": "false",
40+
}
41+
resp = httpx.get(url, params=params, timeout=30)
42+
data = resp.json()
43+
if not data.get("features"):
44+
return None
45+
46+
return data["features"][0]["attributes"]
47+
48+
49+
def get_state_from_point(lon: float, lat: float) -> str:
50+
attrs = get_tiger_data(lon, lat, layer=0, outfields="BASENAME")
51+
return attrs["BASENAME"]
52+
53+
54+
def get_county_from_point(lon: float, lat: float) -> str:
55+
"""
56+
Look up county for a given longitude/latitude
57+
using the US Census TIGERWeb REST API.
58+
"""
59+
60+
attrs = get_tiger_data(lon, lat, layer=1, outfields="BASENAME")
61+
return attrs["BASENAME"]
62+
63+
64+
def get_quad_name_from_point(lon: float, lat: float) -> str:
65+
url = "https://carto.nationalmap.gov/arcgis/rest/services/map_indices/MapServer/10/query"
66+
params = {
67+
"f": "json",
68+
"geometry": f"{lon},{lat}",
69+
"geometryType": "esriGeometryPoint",
70+
"inSR": f"{SRID_WGS84}",
71+
"spatialRel": "esriSpatialRelIntersects",
72+
"outFields": "CELL_NAME,CELL_MAPCODE",
73+
"returnGeometry": "false",
74+
}
75+
76+
resp = httpx.get(url, params=params, timeout=30)
77+
data = resp.json()
78+
79+
if data["features"]:
80+
attrs = data["features"][0]["attributes"]
81+
return attrs["CELL_NAME"]
82+
else:
83+
print(f"No quad name found for POINT ({lon} {lat})")
84+
return None
85+
86+
87+
def get_epqs_elevation_from_point(lon: float, lat: float) -> float:
88+
url = "https://epqs.nationalmap.gov/v1/json"
89+
params = {
90+
"x": lon,
91+
"y": lat,
92+
"units": "Meters",
93+
"wkid": f"{SRID_WGS84}",
94+
"includeDate": False,
95+
}
96+
97+
resp = httpx.get(url, params=params)
98+
data = resp.json()
99+
100+
return data["value"]
101+
102+
103+
if __name__ == "__main__":
104+
x = -106.904107
105+
y = 34.068198
106+
107+
print(get_state_from_point(x, y))
108+
print(get_county_from_point(x, y))
109+
print(get_quad_name_from_point(x, y))
110+
print(get_epqs_elevation_from_point(x, y))

0 commit comments

Comments
 (0)