Skip to content

Commit c2d559e

Browse files
authored
Merge pull request #43 from cal-itp/stm-report
STM report cont
2 parents ef2a294 + 2ae940d commit c2d559e

25 files changed

Lines changed: 12012 additions & 2042 deletions

Makefile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
add_precommit:
3+
pip install pre-commit
4+
pre-commit install
5+
6+
# install_env:
7+
# pip install uv && uv sync --all-groups
8+
# make add_precommit
9+
10+
install_env:
11+
pip install uv && uv sync --all-groups
12+
make add_precommit
13+
14+
15+
#uv_setup_project:
16+
# pip install uv
17+
# uv init
18+
# uv add shared-utils
19+
# uv add calitp-data-analysis
20+
# uv lock

_shared_utils/pyproject.toml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
[project]
2+
name = "shared-utils"
3+
version = "5.0.5"
4+
requires-python = ">=3.11.0, <3.12.0"
5+
dependencies = [
6+
"calitp-data-analysis",
7+
"altair==6.0.0",
8+
"altair-transform==0.2.0",
9+
"branca>=0.7.0",
10+
"dask>=2024.10.0,<2025.4.0",
11+
"dask-geopandas>=0.5.0",
12+
"folium>=0.20.0,<0.21.0",
13+
"gcsfs==2024.5.0",
14+
"great_tables==0.20.0",
15+
"intake==0.6.4",
16+
"intake-geopandas>=0.3.0",
17+
"intake-parquet",
18+
"itables==2.7.3",
19+
"loguru>=0.6.0,<0.7",
20+
"mapclassify>=2.8.0,<3",
21+
"matplotlib>=3.10.5,<4.0.0",
22+
"movingpandas==0.22.4",
23+
"msgpack>=1.1.2",
24+
"numba>=0.62.1,<0.63.0",
25+
"numpy>=1.26.4,<2.0.0",
26+
"omegaconf==2.3.0",
27+
"openpyxl>=3.1.0",
28+
"polars==1.22.0",
29+
"python-slugify>=8.0.0",
30+
"pyyaml>=6.0",
31+
"quarto==0.1.0",
32+
"quarto-cli==1.6.40; sys_platform != 'win32'",
33+
"scipy>=1.11.0",
34+
"seaborn>=0.11.2,<0.12.0",
35+
"stonesoup==1.8",
36+
"tqdm>=4.66.0",
37+
"xmltodict>=0.13.0,<0.14",
38+
"pyaml>=21.10.1",
39+
]
40+
41+
# [dependency-groups]
42+
# test = [
43+
# "pytest>=9.0.2,<10.0.0",
44+
# "pytest-mock>=3.15.1,<4.0.0",
45+
# "pytest-recording>=0.13.4,<0.14.0",
46+
# "pytest-unordered>=0.7.0,<0.8.0",
47+
# ]
48+
49+
[build-system]
50+
requires = ["hatchling"]
51+
build-backend = "hatchling.build"
52+
53+
[tool.hatch.build.targets.wheel]
54+
packages = ["shared_utils"]
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""
2+
Import ESRI feature layers from open data portal sources.
3+
Export into shared GCS folder to use across projects.
4+
5+
https://github.com/cagov/caldata-mdsa-caltrans-pems/blob/main/jobs/utils/geo.py
6+
"""
7+
8+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
9+
10+
import geopandas as gpd
11+
import pandas as pd
12+
13+
from calitp_data_analysis import utils
14+
from calitp_data_analysis.sql import to_snakecase
15+
16+
SHARED_GCS = "gs://calitp-analytics-data/data-analyses/shared_data/"
17+
18+
COUNTY_POLYGONS_URL = "https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/California_County_Boundaries/FeatureServer/0/query"
19+
20+
CALTRANS_DISTRICTS_URL = (
21+
"https://caltrans-gis.dot.ca.gov/arcgis/rest/services/CHboundary/District_Tiger_Lines/FeatureServer/0/query/"
22+
)
23+
24+
LEGISLATIVE_BASE = "https://services3.arcgis.com/fdvHcZVgB2QSRNkL/arcgis/rest/services/Legislative/FeatureServer/"
25+
26+
LEGISLATIVE_DICT = {
27+
"ca_assembly_districts": f"{LEGISLATIVE_BASE}0/query/",
28+
"ca_senate_districts": f"{LEGISLATIVE_BASE}1/query/",
29+
"ca_congressional_districts": f"{LEGISLATIVE_BASE}2/query/",
30+
}
31+
32+
CALTRANS_BASE = "https://caltrans-gis.dot.ca.gov/arcgis/rest/services/CHhighway/"
33+
CRS_FUNCTIONAL_CLASSICIATION_URL = f"{CALTRANS_BASE}CRS_Functional_Classification/FeatureServer/0/query/"
34+
SHN_LINES_URL = f"{CALTRANS_BASE}SHN_Lines/FeatureServer/0/query/"
35+
SHN_POSTMILES_URL = f"{CALTRANS_BASE}SHN_Postmiles_Tenth/FeatureServer/0/query/"
36+
37+
38+
def gdf_from_esri_feature_service(url):
39+
"""
40+
Load an Esri Feature Service to a GeoDataFrame.
41+
42+
Given a URL to an Esri Feature Service, download the features
43+
as GeoJSON, and put them into a GeoDataFrame.
44+
"""
45+
parsed = urlparse(url)
46+
47+
# Ensure we are using the query endpoint of the feature service
48+
if not parsed.path.endswith("/query"):
49+
parsed = parsed._replace(path=parsed.path + "/query")
50+
51+
# Keep grabbing data using the resultOffset until there is no more left
52+
offset = 0
53+
gdfs = []
54+
while True:
55+
queries = dict(parse_qsl(parsed.query))
56+
queries.update(
57+
{
58+
"where": "1=1", # Ensure all rows
59+
"f": "geojson", # Ensure GeoJSON
60+
"outFields": "*", # Ensure all columns
61+
"resultOffset": str(offset), # offset the start
62+
"returnGeometry": "true", # Yes we want geometries
63+
}
64+
)
65+
offset_url = urlunparse(parsed._replace(query=urlencode(queries)))
66+
67+
gdf = gpd.read_file(offset_url, driver="GeoJSON")
68+
if len(gdf) == 0:
69+
break
70+
71+
gdfs.append(gdf)
72+
offset += len(gdf)
73+
return pd.concat(gdfs).reset_index(drop=True)
74+
75+
76+
def combine_legislative_districts(assembly_districts_url: str, senate_districts_url: str) -> gpd.GeoDataFrame:
77+
"""
78+
Create a combined assembly district and senate districts
79+
gdf.
80+
"""
81+
assembly_districts = gdf_from_esri_feature_service(assembly_districts_url)
82+
senate_districts = gdf_from_esri_feature_service(senate_districts_url)
83+
84+
gdf = pd.concat(
85+
[
86+
assembly_districts[["AssemblyDistrictLabel", "geometry"]].rename(
87+
columns={"AssemblyDistrictLabel": "legislative_district"}
88+
),
89+
senate_districts[["SenateDistrictLabel", "geometry"]].rename(
90+
columns={"SenateDistrictLabel": "legislative_district"}
91+
),
92+
],
93+
axis=0,
94+
ignore_index=True,
95+
)
96+
97+
return gdf
98+
99+
100+
def exclude_columns(
101+
gdf: gpd.GeoDataFrame, list_of_cols: list = ["objectid", "shape__area", "shape__length"]
102+
) -> gpd.GeoDataFrame:
103+
"""
104+
Drop a couple of columns that tend to show up for ESRI.
105+
"""
106+
for c in list_of_cols:
107+
if c in gdf.columns:
108+
gdf = gdf.drop(columns=c)
109+
110+
return gdf
111+
112+
113+
if __name__ == "__main__":
114+
esri_datasets = {
115+
"ca_county": COUNTY_POLYGONS_URL,
116+
"caltrans_districts": CALTRANS_DISTRICTS_URL,
117+
"ca_congressional_districts": LEGISLATIVE_DICT["ca_congressional_districts"],
118+
"state_highway_network_raw": SHN_LINES_URL,
119+
"state_highway_network_postmiles": SHN_POSTMILES_URL,
120+
"public_road_functional_classification": CRS_FUNCTIONAL_CLASSICIATION_URL,
121+
}
122+
for dataset_name, url in esri_datasets.items():
123+
print(dataset_name)
124+
gdf = gdf_from_esri_feature_service(url)
125+
gdf = gdf.pipe(to_snakecase).pipe(exclude_columns)
126+
127+
print(gdf.crs)
128+
print(gdf.shape)
129+
utils.geoparquet_gcs_export(gdf, SHARED_GCS, dataset_name)
130+
del gdf
131+
132+
legislative_districts_gdf = combine_legislative_districts(
133+
LEGISLATIVE_DICT["ca_assembly_districts"],
134+
LEGISLATIVE_DICT["ca_senate_districts"],
135+
)
136+
137+
utils.geoparquet_gcs_export(legislative_districts_gdf, SHARED_GCS, "legislative_districts")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
pandas_gbq utils to download Big Query tables
3+
"""
4+
5+
from typing import Literal
6+
7+
import geopandas as gpd
8+
import google.auth
9+
import pandas as pd
10+
import pandas_gbq
11+
from shared_utils import geo_utils
12+
13+
credentials, project = google.auth.default()
14+
15+
16+
def basic_sql_query(project_name: str, dataset_name: str, table_name: str) -> str:
17+
"""
18+
Set up the basic sql query needed, which is the entire table.
19+
"""
20+
sql_query = f"SELECT * FROM `{project_name}`.`{dataset_name}`.`{table_name}`"
21+
22+
return sql_query
23+
24+
25+
def add_sql_date_filter(date_col: str, start_date: str, end_date: str) -> str:
26+
"""
27+
Add a where condition to filter by date, coerce the dates so sql_query is read correctly.
28+
"""
29+
where_condition = f"WHERE {date_col} >= DATE('{start_date}') AND {date_col} <= DATE('{end_date}')"
30+
31+
return where_condition
32+
33+
34+
def download_table(
35+
project_name: str = "cal-itp-data-infra",
36+
dataset_name: str = "mart_gtfs",
37+
table_name: str = "",
38+
date_col: Literal["service_date", "month_first_day", None] = "",
39+
start_date: str = "",
40+
end_date: str = "",
41+
geom_col: str = None,
42+
geom_type: Literal["point", "line"] = None,
43+
) -> Literal[pd.DataFrame, gpd.GeoDataFrame]:
44+
"""
45+
Set up a basic query and use pandas_gbq to import.
46+
Coerce datetime column and convert to gdf if needed.
47+
"""
48+
basic_query = basic_sql_query(project_name, dataset_name, table_name)
49+
where_condition = add_sql_date_filter(date_col, start_date, end_date)
50+
sql_query_statement = f"{basic_query} {where_condition}"
51+
52+
if date_col is None:
53+
df = pandas_gbq.read_gbq(basic_query, project_id=project_name, dialect="standard", credentials=credentials)
54+
55+
print(f"query: {basic_query}")
56+
57+
if date_col is not None:
58+
df = pandas_gbq.read_gbq(
59+
sql_query_statement, project_id=project_name, dialect="standard", credentials=credentials
60+
).astype({date_col: "datetime64[ns]"})
61+
62+
print(f"query: {sql_query_statement}")
63+
64+
if geom_col is not None:
65+
df = geo_utils.convert_to_gdf(df, geom_col, geom_type)
66+
67+
return df
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Functions for opening yaml catalogs in shared_utils.
3+
"""
4+
5+
from pathlib import Path
6+
from typing import Literal
7+
8+
import intake
9+
from omegaconf import OmegaConf # this is yaml parser
10+
11+
shared_utils_directory = "data-analyses/_shared_utils/shared_utils/"
12+
13+
14+
def get_catalog_file(catalog_name, home_path=Path.home(), current_path=Path.cwd()):
15+
filename = f"{shared_utils_directory}{catalog_name}.yml"
16+
parent_directory = current_path
17+
18+
if home_path not in current_path.parents:
19+
raise RuntimeError("The data-analyses repo should be located in your home directory.")
20+
21+
while True:
22+
test_path = parent_directory.joinpath(filename)
23+
24+
if test_path.is_file():
25+
return test_path
26+
27+
if parent_directory == home_path:
28+
raise FileNotFoundError(f"No such catalog file found: {filename}")
29+
30+
parent_directory = parent_directory.parent
31+
32+
33+
def get_catalog(catalog_name: Literal["shared_data_catalog", "gtfs_analytics_data"]) -> Path:
34+
"""
35+
Grab either the shared_data_catalog (uses intake driver) or
36+
gtfs_analytics_data catalog (uses OmegaConf yaml parser).
37+
38+
"""
39+
catalog_path = get_catalog_file(catalog_name)
40+
41+
if catalog_name == "gtfs_analytics_data":
42+
return OmegaConf.load(catalog_path)
43+
44+
else:
45+
return intake.open_catalog(catalog_path)

0 commit comments

Comments
 (0)