Finding Denver's up-and-coming neighborhoods
This example shows how to run an area identification screen for one metro: score every Census Block Group in Denver on explicit indicators and rank the risers, the neighborhoods drawing more people, and more people who choose to come.
For this we use our US Neighborhood metrics, queried from Python. The helper below is all the setup the example needs:
import os
import requests
import pandas as pd
API = "https://api.pine59.com/v3/datasets"
HEADERS = {"Authorization": f"Bearer {os.environ['PINE59_API_KEY']}"}
def run_query(dataset, body):
r = requests.post(f"{API}/{dataset}:runQuery", json=body, headers=HEADERS)
r.raise_for_status()
return [rec["fields"] for rec in r.json().get("records", [])]
1. Define the candidate universe
The universe is every block group in the Denver metro, and the scoring window is 2025. neighborhood_visitation.foot_traffic_month gives monthly visits per block group split into residents, workers and destination visitors, with the metro embedded as us_cbsa, so the universe is one filter. We pull two years, because the momentum indicator needs the same months a year earlier, and we pull one month per request: a metro-month is about 2,000 rows, so every response fits in one page.
CBSA = "Denver-Aurora-Lakewood, CO Metro Area"
months = [f"{y}-{m:02d}-01" for y in (2024, 2025) for m in range(1, 13)]
rows = []
for month in months:
page = run_query("neighborhood_visitation.foot_traffic_month", {
"fields": [
{"name": "location_id"},
{"name": "visits_sum", "aggregation": "SUM"},
{"name": "non_resident_non_worker_visits_sum", "aggregation": "SUM"},
],
"filters": [
{"fieldName": "us_cbsa", "operator": "==", "value": CBSA},
{"fieldName": "observation_start_date", "operator": "==", "value": month},
],
"groupBy": [{"fieldName": "location_id"}],
"pageSize": 10000,
})
for rec in page:
rec["month"] = month
rows += page
df = pd.DataFrame(rows)
df["visits"] = df["visits_sum_SUM"].astype(float)
df["dest"] = pd.to_numeric(df["non_resident_non_worker_visits_sum_SUM"], errors="coerce")
df["year"] = df["month"].str[:4]
print(df.groupby("year")["location_id"].nunique())
2,048 block groups in each year, about 49,000 rows in total. Verify the exact us_cbsa string for another metro with searchFieldValues before filtering on it.
2. Build the indicators
One pass over the pull gives four indicators per block group: volume, destination share (the share of visitors who neither live nor work there), momentum, and a summer index. Momentum is the same months a year apart, so seasonality cancels out of it: July to November 2025 against July to November 2024.
Why that window. The dataset has a metro-wide step between June and July 2024 (Denver's total visits go from 344M to 435M month over month), and identical-month windows must sit on one side of it. December 2025 is a partial month (1,912 of the 2,048 block groups have a row), so the window stops in November. Both windows are complete and both sit after the step.
def jul_nov(year):
window = df[(df["month"] >= f"{year}-07-01") & (df["month"] < f"{year}-12-01")]
return window.groupby("location_id")["visits"].sum()
y = df[df.year == "2025"].groupby("location_id")
ind = pd.DataFrame({
"volume": y["visits"].sum(), # activity volume
"dest_share": y["dest"].sum() / y["visits"].sum(), # visitors who neither live nor work there
"momentum": jul_nov("2025") / jul_nov("2024") - 1, # Jul-Nov 2025 vs. Jul-Nov 2024
})
summer = df[df["month"].isin(["2025-06-01", "2025-07-01", "2025-08-01"])]
ind["summer_index"] = summer.groupby("location_id")["visits"].mean() / y["visits"].mean()
print(ind["momentum"].median())
The metro as a whole is down 10% on this window and the median block group is down 9%, so momentum is read relative to the universe, which the percentile ranking below does.
Category values are suppressed below privacy thresholds in low-activity block groups, so the categories can sum to less than
visits_sum. Compute shares againstvisits_sum, and treat a missing category as missing, not zero (errors="coerce"above does that).
3. Add an audience indicator
"Up-and-coming" usually means young adults arriving, so the audience indicator is the share of visitors aged 18 to 29. neighborhood_visitation.census_visitor_demographics returns visitor shares per block group, quarterly; we average the four 2025 quarters:
demo = pd.DataFrame(run_query("neighborhood_visitation.census_visitor_demographics", {
"fields": [
{"name": "location_id"},
{"name": "people_fraction_age_18_29", "aggregation": "AVG"},
],
"filters": [
{"fieldName": "us_cbsa", "operator": "==", "value": CBSA},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2025-01-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
],
"groupBy": [{"fieldName": "location_id"}],
"pageSize": 10000,
})).set_index("location_id")
ind["target_share"] = demo["people_fraction_age_18_29_AVG"].astype(float)
ind["target_volume"] = ind["target_share"] * ind["volume"]
Two indicators come out of one field, and they answer different questions. target_share ranks by composition and favors small areas with a lean mix; target_volume ranks by how many young adults are actually there and favors big areas. For siting and media questions the volume version is the one to score on; keep the share as a display column.
4. Composite score
Gate first, then percentile-rank every indicator within the metro, then weight:
gated = ind[ind["volume"] > 50_000].dropna()
pct = gated.rank(pct=True)
weights = {
"momentum": 0.30, # the question is who is rising
"dest_share": 0.25, # rising destination pull, not commuter churn
"target_volume": 0.20, # rising with the audience we care about
"volume": 0.15, # enough scale for the trend to mean something
"summer_index": 0.10,
}
gated["score"] = sum(w * pct[k] for k, w in weights.items())
shortlist = gated.nlargest(25, "score")
# Sensitivity: move 10 points from destination share to momentum and re-rank
alt = dict(weights, momentum=0.40, dest_share=0.15)
alt_top = sum(w * pct[k] for k, w in alt.items()).nlargest(25).index
print(len(shortlist.index.intersection(alt_top)), "of 25 survive the re-weighting")
The weighting is the question stated as numbers. "Up-and-coming" puts momentum and destination share on top with volume as ballast; an expansion screen would move volume and audience to the top, a seasonal concept would move 20 points onto the summer index. The re-weighting check is the discipline that goes with it: 17 of the 25 survive here, so most of the shortlist is a property of the areas, not of the exact weights; the eight that swap are the ones to treat as marginal.
5. Results
The screen on Denver, 2025 data, 2,048 block groups, 2,047 above the volume gate. The top of the ranking:
| Rank | Area¹ | 2025 visits | Destination share | Momentum, Jul-Nov 2025 vs. Jul-Nov 2024 | Young-adult share |
|---|---|---|---|---|---|
| 1 | Black Hawk casino district, Gilpin County | 5.3M | 78% | +23% | 23% |
| 2 | Edgewater, West Colfax | 4.5M | 75% | +30% | 25% |
| 3 | Lakewood, West Colfax | 5.6M | 73% | +22% | 22% |
| 4 | Aurora, I-225 corridor | 6.4M | 72% | +30% | 26% |
| 5 | Westminster, Federal Boulevard | 6.0M | 71% | +30% | 24% |
| 6 | Thornton, I-25 at 104th | 9.1M | 77% | +13% | 28% |
| 7 | Thornton, north I-25 corridor | 8.8M | 70% | +22% | 26% |
| 8 | Thornton, south I-25 corridor | 10.1M | 75% | +8% | 24% |
How to read it: two patterns share the top. The West Colfax stretch through Edgewater and Lakewood rises on momentum in a metro that is down on the window, with an above-average young-adult share; the suburban corridors (Aurora's I-225, Thornton's I-25, Westminster's Federal Boulevard) follow on scale and destination pull. Number one is the reminder to read the raw indicators, not only the composite: a casino district scores high on every dimension a hospitality screen wants, and is the wrong answer for a coffee chain. Cross-check the list against what you know of the market.
To put the shortlist on a map, pull the boundaries for the 25 block groups (each row of census_block_groups carries the polygon as GeoJSON):
polys = run_query("neighborhood_visitation.census_block_groups", {
"fields": [{"name": "location_id"}, {"name": "polygon"}],
"filters": [{"fieldName": "location_id", "operator": "in",
"values": list(shortlist.index)}],
"pageSize": 25,
})
print(len(polys), "polygons") # write to GeoJSON, or plot with geopandas
Before a surprising riser goes into a deck, check its monthly curve. The Black Hawk block group climbs through 2024 (from about 240k visits in January to 440k by November) and holds a higher level through 2025, above 500k a month from July: a sustained rise, not a one-season spike. Its December 2025 row is one of the missing ones. The identical-month figure, +23% against July to November 2024, is the number to use; the window choice decides who makes the shortlist.
The shortlist is where the ground work starts: a store-level profile of what already operates in a shortlisted area, or a void analysis of what it lacks, from 25 candidates instead of 2,000.
¹ Area names are readability labels inferred from each block group's centroid (from the census_block_groups polygons). The data identifies block groups by GEOID, not by neighborhood.