What people ask Edmonton Open Data for, and what happens to it¶
Dataset: Dataset Requests (a9w6-s3na) on data.edmonton.ca. It is
refreshed daily and holds every request since automated intake began on
2016-01-26.
Two jobs:
- Duplicate check (§6). Before this project sends a data request, find out whether someone has already asked for it and what the City answered.
- Landscape (§3–§5). What people request over time, what gets published, and what gets refused or left open.
⚠️ Read §2 before quoting any count. The table mixes two intake systems
with different status vocabularies. In December 2023, 126 old requests that
had never been decided were moved to the new tracker, and on the old system
they were stamped Rejected or Declined as they went. A single-day bulk
edit in August 2026 also reset status_date on 241 rows, and two 2023
clean-up days declined 16 requests from 2016 about seven years late.
Counted naively, the table double-counts requests, overstates decided
refusals by more than 3×, and misdates decisions.
Reproducing¶
pip install pandas matplotlib certifi
Public endpoint, no API token.
import json
import re
import ssl
import urllib.parse
import urllib.request
from datetime import datetime, timezone
import certifi
import matplotlib.pyplot as plt
import pandas as pd
from IPython.display import Markdown, display
UA = "edmonton-tax-viz/dataset-requests (open-data landscape)"
SSL_CTX = ssl.create_default_context(cafile=certifi.where())
RUN_AT = datetime.now(timezone.utc)
REQUESTS = "a9w6-s3na"
LIMIT = 50000
pd.set_option("display.max_colwidth", 90, "display.width", 200)
CHECKS: list[tuple[bool, str]] = []
def check(ok: bool, claim: str) -> None:
CHECKS.append((bool(ok), claim))
print(f" [{'PASS' if ok else 'FAIL'}] {claim}")
def soda(dataset: str, params: dict) -> list[dict]:
url = (f"https://data.edmonton.ca/resource/{dataset}.json?"
+ urllib.parse.urlencode(params))
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, context=SSL_CTX, timeout=120) as r:
return json.loads(r.read())
raw = pd.DataFrame(soda(REQUESTS, {"$limit": LIMIT}))
d = raw.copy()
for c in ["request_creation_date", "status_date", "dataset_published_date"]:
d[c] = pd.to_datetime(d[c])
d["department"] = d["department"].fillna("(blank)")
display(Markdown(f"**Snapshot.** Executed against live data **{RUN_AT:%Y-%m-%d}** "
f"(UTC): **{len(d):,} requests**, created "
f"{d.request_creation_date.min():%Y-%m-%d} to "
f"{d.request_creation_date.max():%Y-%m-%d}. The source changes "
"daily, so re-execute before citing a figure."))
check(len(d) < LIMIT, f"one page holds the whole table ({len(d)} < {LIMIT}), so no rows were cut off")
check(d.request_number.is_unique, "request_number is unique")
Snapshot. Executed against live data 2026-09-23 (UTC): 656 requests, created 2016-01-26 to 2026-08-19. The source changes daily, so re-execute before citing a figure.
[PASS] one page holds the whole table (656 < 50000), so no rows were cut off [PASS] request_number is unique
1. Two intake systems, two status vocabularies¶
request_number gives away which system took the request:
ODRyy-nnn: the original intake, from 2016 until its last entry in 2024. Statuses are Title-case:Completed,Rejected,Declined,Cancelled,New.- Opaque ids such as
868…: the newer tracker, from October 2021 on. Statuses are UPPER-case:NEW,IN PROGRESS,PAUSED,CLOSED.
⚠️ The newer system has no way to say "refused" and no way to say
"published". Everything that ends goes to CLOSED. Old-system outcomes
therefore can't be compared one-for-one with new-system outcomes, and
CLOSED is shown below as its own bucket instead of being folded into
"done".
d["system"] = d.request_number.str.startswith("ODR").map({True: "ODR (old)", False: "tracker (new)"})
print(pd.crosstab(d.status, d.system, margins=True))
print()
print(d.groupby("system").request_creation_date.agg(["min", "max", "count"]))
OUTCOME = {
"Completed": "Completed",
"Rejected": "Refused", "Declined": "Refused",
"CLOSED": "Closed (reason not given)",
"NEW": "Open", "New": "Open", "IN PROGRESS": "Open", "PAUSED": "Open",
"Cancelled": "Withdrawn",
}
d["outcome"] = d.status.map(OUTCOME)
unmapped = sorted(d.loc[d.outcome.isna(), "status"].unique())
check(not unmapped, f"every status maps to an outcome bucket (unmapped: {unmapped or 'none'})")
d["outcome"] = d.outcome.fillna("Unmapped")
system ODR (old) tracker (new) All
status
CLOSED 0 152 152
Cancelled 19 0 19
Completed 146 0 146
Declined 50 0 50
IN PROGRESS 0 24 24
NEW 0 92 92
New 1 0 1
PAUSED 0 18 18
Rejected 154 0 154
All 370 286 656
min max count
system
ODR (old) 2016-01-26 15:32:27 2024-09-06 15:40:23 370
tracker (new) 2021-10-29 09:30:13 2026-08-19 15:37:21 286
[PASS] every status maps to an outcome bucket (unmapped: none)
dataset_published_date on a CLOSED row does not mean something was published¶
Every CLOSED row has a dataset_published_date, including requests the
City could not have fulfilled publicly. One example is below. On the new
tracker the field is best read as "date closed".
closed = d[d.status == "CLOSED"]
check(closed.dataset_published_date.notna().all(),
f"all {len(closed)} CLOSED rows carry a dataset_published_date")
ex = d[d.request_details.fillna("").str.contains("will not be publicly visible")]
print(ex[["request_number", "status", "dataset_published_date", "request_description", "request_details"]].to_string(index=False))
[PASS] all 152 CLOSED rows carry a dataset_published_date
request_number status dataset_published_date request_description request_details
8688a6173 CLOSED 2024-06-14 15:17:57 Property and Education Tax Rates prior to 2014 and as far back as available, ideally 1960? This information is requested to perform modeling of historical changes to the assessment values of properties before 2014. This is a private study and will not be publicly visible.
8688a6366 CLOSED 2024-06-14 15:17:12 Historical Property Assessment Data prior to 2012. Ideally between 1960 and 2012, this data will not be publicly visible.
2. Two artifacts that distort naive counts¶
2a. The December 2023 migration: "Rejected" here means "moved"¶
On 2023-12-12 and 2023-12-13, old-system requests were re-entered in the new
tracker under new ids and new creation dates. On the same two days, their
old-system rows were set to Rejected or Declined. The pairs are matched
by normalised title, restricted to old rows whose status_date falls on
those two days.
These were not refusals. The request carried on in the new tracker, and many are still open there. Most had been created years earlier with no status change since, so they had simply never been decided. Taking the old status at face value overstates refusals.
The notebook handles the pairs as follows:
- Volume over time: each pair is counted once, in the year the request was first made. The new-tracker row is dropped.
- Outcome: the old row takes its outcome from the new-tracker row, which
is where the request actually stands now. A
movedflag records that it was never decided on the old system.
norm = lambda s: re.sub(r"[^a-z0-9]", "", str(s).lower())
d["title_key"] = d.request_description.map(norm)
MIGRATION_DAYS = {"2023-12-12", "2023-12-13"}
on_migration_day = d.status_date.dt.strftime("%Y-%m-%d").isin(MIGRATION_DAYS)
created_on_migration_day = d.request_creation_date.dt.strftime("%Y-%m-%d").isin(MIGRATION_DAYS)
odr_stamped = d[(d.system == "ODR (old)") & on_migration_day]
new_side = d[(d.system == "tracker (new)") & created_on_migration_day]
pairs = odr_stamped.merge(new_side, on="title_key", suffixes=("_orig", ""))
check(pairs.request_number_orig.is_unique and pairs.request_number.is_unique,
f"migration pairs match one-to-one by title ({len(pairs)} pairs)")
d["reintake"] = d.request_number.isin(pairs.request_number)
d["moved"] = d.request_number.isin(pairs.request_number_orig)
d.loc[d.moved, "outcome"] = d.loc[d.moved, "request_number"].map(
pairs.set_index("request_number_orig").outcome)
print(f"old-system rows stamped on the migration days: {len(odr_stamped)}")
print(f" ...of which paired with a new-tracker row: {len(pairs)}")
print("\nOld-system stamp × where the request stands now (new tracker):")
print(pd.crosstab(pairs.status_orig, pairs.status, margins=True))
print("\nOld-system creation year of the moved requests:")
print(pairs.request_creation_date_orig.dt.year.value_counts().sort_index().to_string())
check(len(pairs) >= 100, f"the Dec-2023 migration is still visible ({len(pairs)} pairs)")
check(len(odr_stamped) - len(pairs) <= 2,
f"nearly every old row stamped on the migration days was moved "
f"({len(pairs)} of {len(odr_stamped)}), so the stamp means 'moved', not 'refused'")
check(set(odr_stamped.status) <= {"Rejected", "Declined"},
"every migration-day stamp is Rejected/Declined, the statuses a naive reading counts as refusals")
[PASS] migration pairs match one-to-one by title (126 pairs) old-system rows stamped on the migration days: 127 ...of which paired with a new-tracker row: 126 Old-system stamp × where the request stands now (new tracker): status CLOSED IN PROGRESS NEW PAUSED All status_orig Declined 10 7 6 0 23 Rejected 43 7 49 4 103 All 53 14 55 4 126 Old-system creation year of the moved requests: request_creation_date_orig 2016 11 2017 27 2018 21 2019 24 2020 17 2021 5 2022 10 2023 11 [PASS] the Dec-2023 migration is still visible (126 pairs) [PASS] nearly every old row stamped on the migration days was moved (126 of 127), so the stamp means 'moved', not 'refused' [PASS] every migration-day stamp is Rejected/Declined, the statuses a naive reading counts as refusals
2b. status_date is the date the row was last edited, not the date of the decision¶
Large blocks of rows share a single status_date. That is a bulk edit, not
hundreds of decisions made on one day. Durations such as "time to decision"
computed from status_date are therefore unreliable on the new tracker, and
this notebook doesn't compute them.
top_days = d.status_date.dt.date.value_counts().head(5)
print(top_days)
biggest = top_days.index[0]
print(f"\n{biggest}: statuses on the bulk-edit day")
print(d[d.status_date.dt.date == biggest].status.value_counts())
check(top_days.iloc[0] >= 100, f"a bulk status_date sweep is present ({top_days.iloc[0]} rows on {biggest})")
status_date 2026-08-19 241 2023-12-13 104 2026-08-26 32 2023-12-12 23 2023-09-19 16 Name: count, dtype: int64 2026-08-19: statuses on the bulk-edit day status CLOSED 139 NEW 62 IN PROGRESS 24 PAUSED 16 Name: count, dtype: int64 [PASS] a bulk status_date sweep is present (241 rows on 2026-08-19)
2c. Two 2023 clean-up days, and a test row¶
status_date spikes also sit inside the old system's refusals. On
2023-06-07 and 2023-09-19, requests made in 2016 were declined in batches,
about seven years after they were asked. That is backlog housekeeping, not a
considered refusal, so §5 counts those rows as closed in a clean-up sweep
instead of refused. A row is swept only if it was declined on one of those
days and more than a year after it was made; a request refused the day
after it arrived is still a refusal.
One row is titled test with details test. It is removed from every count
below and printed here.
SWEEP_DAYS = {"2023-06-07", "2023-09-19"}
d["swept"] = ((d.system == "ODR (old)") & d.status.isin(["Rejected", "Declined"])
& d.status_date.dt.strftime("%Y-%m-%d").isin(SWEEP_DAYS)
& ((d.status_date - d.request_creation_date).dt.days > 365))
print(d[d.swept].groupby([d.status_date.dt.date, d.request_creation_date.dt.year]).size().to_string())
check(d.swept.sum() >= 10, f"the 2023 clean-up sweeps are still visible ({d.swept.sum()} rows)")
test_rows = d[d.request_description.fillna("").str.strip().str.lower().eq("test")
& d.request_details.fillna("").str.strip().str.lower().isin(["test", ""])]
print("\nremoved as test entries:")
print(test_rows[["request_number", "status", "request_description", "request_details"]].to_string(index=False))
status_date request_creation_date
2023-06-07 2016 5
2023-09-19 2016 11
[PASS] the 2023 clean-up sweeps are still visible (16 rows)
removed as test entries:
request_number status request_description request_details
ODR19-269 Rejected test test
3. Volume over time¶
Requests by the year they were first made, each counted once (§2a). Colours show each request's current outcome. Moved requests show where they stand on the new tracker.
u = d[~d.reintake & ~d.request_number.isin(test_rows.request_number)].copy()
u["year"] = u.request_creation_date.dt.year
ORDER = ["Completed", "Refused", "Closed (reason not given)", "Open", "Withdrawn"]
COLOURS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"] # dataviz reference palette, slots 1-5 in order
by_year = pd.crosstab(u.year, u.outcome).reindex(columns=ORDER, fill_value=0)
display(by_year.assign(total=by_year.sum(axis=1)))
fig, ax = plt.subplots(figsize=(9, 4.2))
bottom = pd.Series(0, index=by_year.index)
for col, colour in zip(ORDER, COLOURS):
ax.bar(by_year.index, by_year[col], bottom=bottom, color=colour, label=col,
width=0.72, edgecolor="#fcfcfb", linewidth=1.5)
bottom += by_year[col]
ax.set_title("Dataset requests by year first made, coloured by current outcome", loc="left", fontsize=11)
ax.set_ylabel("requests")
ax.set_xticks(by_year.index)
ax.spines[["top", "right"]].set_visible(False)
ax.grid(axis="y", color="#e6e5e0", linewidth=0.8)
ax.set_axisbelow(True)
ax.legend(frameon=False, fontsize=8, ncol=5, loc="upper center", bbox_to_anchor=(0.5, -0.1))
this_year = RUN_AT.year
ax.annotate(f"{this_year}: part-year", (this_year, by_year.loc[this_year].sum() if this_year in by_year.index else 0),
textcoords="offset points", xytext=(0, 6), ha="center", fontsize=8, color="#52514e")
plt.tight_layout()
plt.show()
| outcome | Completed | Refused | Closed (reason not given) | Open | Withdrawn | total |
|---|---|---|---|---|---|---|
| year | ||||||
| 2016 | 46 | 40 | 6 | 5 | 14 | 111 |
| 2017 | 28 | 6 | 16 | 11 | 1 | 62 |
| 2018 | 26 | 1 | 5 | 16 | 3 | 51 |
| 2019 | 17 | 8 | 8 | 16 | 1 | 50 |
| 2020 | 14 | 8 | 6 | 11 | 0 | 39 |
| 2021 | 10 | 1 | 7 | 4 | 0 | 22 |
| 2022 | 2 | 4 | 7 | 7 | 0 | 20 |
| 2023 | 3 | 9 | 23 | 12 | 0 | 47 |
| 2024 | 0 | 0 | 33 | 22 | 0 | 55 |
| 2025 | 0 | 0 | 30 | 20 | 0 | 50 |
| 2026 | 0 | 0 | 11 | 11 | 0 | 22 |
How to read it:
- 2016 was the intake's first year and is its busiest. Only 11 of its rows are hackathon whiteboard captures (measured 2026-09-22), so hackathons don't explain the peak.
- Everything filed after the switch to the new tracker is either
Closed (reason not given)or stillOpen. The drop in blue and orange after 2022 comes from the vocabulary change in §1. It is not a change in how the City decides. - Green and yellow before 2021 are the moved requests: they were never decided on the old system, and they now sit on the new tracker.
4. What people request¶
Topics are assigned by keyword over title and details. Each request goes to the first topic in the list below that matches, so every request is counted once. This is a transparent heuristic, not a classifier, and the patterns are printed so they can be argued with.
TOPICS = [
("Transit", r"\bets\b|transit|\bbus(?:es)?\b|lrt|gtfs|arc card|ridership|\btrain"),
("Property, assessment & tax", r"assess|parcel|propert|\btax|\blot\b|lot size|title"),
("Planning, permits & zoning", r"permit|zoning|land use|development|bylaw \d|\barp\b|rezon|licen[cs]e"),
("Roads, traffic & mobility", r"road|traffic|collision|speed|parking|snow|sidewalk|bike|cycl|pathway|pedestrian|intersection|radar|scooter|micromobility|bridge|pedway"),
("Utilities & drainage", r"drain|sewer|water|epcor|\bgas\b|power|electric|utilit"),
("Public safety", r"crime|police|fire|911|emergenc|bylaw enforcement|complain"),
("Parks, trees & environment", r"\bpark|\btrees?\b|green|waste|garbage|trash|recycl|climate|emission|river|ravine|natural area|cemeter|washroom|picnic|graffiti|ashtray|flood"),
("Recreation, library & community", r"recreation|library|leisure|community|arena|pool|school|playground"),
("People & census", r"census|population|demograph|income|housing|homeless|senior"),
("Budget & spending", r"budget|spend|\bcost|expend|revenue|contract|procure|salar|financial statement|expense|severance|capital project|renewal invest|funding|earnings"),
("Council, elections & administration", r"council|election|\bward|voting|candidate|meeting|motion|agenda|lobby|foip|\b311\b|job|rfp|disclosure"),
("Base maps & imagery", r"lidar|orthophoto|imagery|elevation|\bdem\b|postal code|neighbou?rhood (?:dataset|boundar)|manhole|historic resource"),
]
def topic_of(text: str) -> str:
t = text.lower()
for name, pat in TOPICS:
if re.search(pat, t):
return name
return "Other / unclassified"
u["text"] = u.request_description.fillna("") + " " + u.request_details.fillna("")
u["topic"] = u.text.map(topic_of)
u["era"] = pd.cut(u.year, [2015, 2019, 2022, 2100], labels=["2016–19", "2020–22", "2023–now"])
tt = pd.crosstab(u.topic, u.era, margins=True, margins_name="all").sort_values("all", ascending=False)
display(tt)
print(f"unclassified: {(u.topic == 'Other / unclassified').mean():.0%} of requests")
| era | 2016–19 | 2020–22 | 2023–now | all |
|---|---|---|---|---|
| topic | ||||
| all | 274 | 81 | 174 | 529 |
| Other / unclassified | 50 | 18 | 24 | 92 |
| Roads, traffic & mobility | 53 | 10 | 18 | 81 |
| Property, assessment & tax | 25 | 6 | 31 | 62 |
| Planning, permits & zoning | 24 | 13 | 19 | 56 |
| Council, elections & administration | 24 | 5 | 13 | 42 |
| Parks, trees & environment | 20 | 7 | 10 | 37 |
| Utilities & drainage | 19 | 9 | 8 | 36 |
| Transit | 12 | 4 | 14 | 30 |
| Recreation, library & community | 17 | 2 | 8 | 27 |
| People & census | 7 | 3 | 16 | 26 |
| Base maps & imagery | 6 | 0 | 8 | 14 |
| Budget & spending | 9 | 0 | 4 | 13 |
| Public safety | 8 | 4 | 1 | 13 |
unclassified: 17% of requests
5. What gets through, what gets refused, what never gets decided¶
5a. Old system, by the year a request was first made¶
The old system is the only one that records an explicit outcome. Each old request lands in one of four groups:
- Completed
- Refused:
RejectedorDeclined, excluding the migration stamps - Never decided: moved to the new tracker in December 2023
- Closed in a clean-up sweep: declined years late on a 2023 sweep day (§2c)
- Withdrawn / still open
old = u[u.system == "ODR (old)"].copy()
old["fate"] = "Withdrawn / still open"
old.loc[old.status == "Completed", "fate"] = "Completed"
old.loc[old.outcome == "Refused", "fate"] = "Refused"
old.loc[old.moved, "fate"] = "Never decided (moved)"
old.loc[old.swept, "fate"] = "Clean-up sweep"
FATES = ["Completed", "Refused", "Never decided (moved)", "Clean-up sweep", "Withdrawn / still open"]
fy = pd.crosstab(old.year, old.fate).reindex(columns=FATES, fill_value=0)
fy["completed share"] = (fy.Completed / fy[FATES].sum(axis=1)).round(2)
display(fy)
tot = old.fate.value_counts()
print(f"old system overall: {tot.get('Completed', 0)} completed, {tot.get('Refused', 0)} refused, "
f"{tot.get('Never decided (moved)', 0)} never decided, {tot.get('Clean-up sweep', 0)} closed in a clean-up sweep, "
f"of {len(old)}")
naive = old.status.isin(["Rejected", "Declined"]).sum()
print(f"a naive count of Rejected+Declined gives {naive} refusals; "
f"the decided figure is {tot.get('Refused', 0)} ({naive / max(tot.get('Refused', 1), 1):.1f}× overstated)")
| fate | Completed | Refused | Never decided (moved) | Clean-up sweep | Withdrawn / still open | completed share |
|---|---|---|---|---|---|---|
| year | ||||||
| 2016 | 46 | 24 | 11 | 16 | 14 | 0.41 |
| 2017 | 28 | 6 | 27 | 0 | 1 | 0.45 |
| 2018 | 26 | 1 | 21 | 0 | 3 | 0.51 |
| 2019 | 17 | 8 | 24 | 0 | 1 | 0.34 |
| 2020 | 14 | 8 | 17 | 0 | 0 | 0.36 |
| 2021 | 10 | 1 | 5 | 0 | 0 | 0.62 |
| 2022 | 2 | 4 | 10 | 0 | 0 | 0.12 |
| 2023 | 3 | 9 | 11 | 0 | 0 | 0.13 |
| 2024 | 0 | 0 | 0 | 0 | 1 | 0.00 |
old system overall: 146 completed, 61 refused, 126 never decided, 16 closed in a clean-up sweep, of 369 a naive count of Rejected+Declined gives 203 refusals; the decided figure is 61 (3.3× overstated)
How to read it: after 2016 the City rarely says no outright. What changed was how many requests never got an answer. From 2017 on, more requests ended up in "never decided" than in "refused" in every year. The completed share falls from roughly 0.4–0.5 in 2016–2018 to about one in eight in 2022–2023. 2021 is the exception, and it has only 16 requests. 2016's refusals halve once the 2023 clean-up sweeps are taken out (§2c).
Two topics come close to refusing as often as they complete (§5b), on small counts. Utilities & drainage (7 refused, 7 completed) is mostly gas, power and water-network mapping. Property, assessment & tax (9 refused, 10 completed) is square footage, land-title transfers and sales, and extra columns on the assessment dataset. Base maps & imagery has too few requests to say.
5b. By topic, old system¶
Topics use the same keyword heuristic as §4. Counts per topic are small, so read the table for its broad shape, not for rank order.
bt = pd.crosstab(old.topic, old.fate).reindex(columns=FATES, fill_value=0)
bt["n"] = bt[FATES].sum(axis=1)
bt["completed share"] = (bt.Completed / bt.n).round(2)
display(bt.sort_values("n", ascending=False))
| fate | Completed | Refused | Never decided (moved) | Clean-up sweep | Withdrawn / still open | n | completed share |
|---|---|---|---|---|---|---|---|
| topic | |||||||
| Other / unclassified | 38 | 8 | 14 | 2 | 4 | 66 | 0.58 |
| Roads, traffic & mobility | 21 | 12 | 23 | 3 | 6 | 65 | 0.32 |
| Planning, permits & zoning | 13 | 4 | 20 | 0 | 0 | 37 | 0.35 |
| Property, assessment & tax | 10 | 9 | 9 | 3 | 3 | 34 | 0.29 |
| Council, elections & administration | 17 | 5 | 8 | 0 | 1 | 31 | 0.55 |
| Utilities & drainage | 7 | 7 | 9 | 6 | 0 | 29 | 0.24 |
| Parks, trees & environment | 10 | 1 | 14 | 0 | 2 | 27 | 0.37 |
| Recreation, library & community | 9 | 3 | 7 | 1 | 0 | 20 | 0.45 |
| Transit | 7 | 2 | 7 | 1 | 1 | 18 | 0.39 |
| Public safety | 4 | 2 | 5 | 0 | 2 | 13 | 0.31 |
| People & census | 5 | 3 | 4 | 0 | 0 | 12 | 0.42 |
| Budget & spending | 4 | 1 | 5 | 0 | 0 | 10 | 0.40 |
| Base maps & imagery | 1 | 4 | 1 | 0 | 1 | 7 | 0.14 |
5b′. The genuine refusals¶
These are old-system Rejected or Declined rows that neither the migration
(§2a) nor a clean-up sweep (§2c) stamped, most recent first. The table records no reason for any refusal, so
only the titles are available.
real_refusals = old[old.fate == "Refused"].sort_values("request_creation_date", ascending=False)
print(real_refusals[["request_number", "status", "request_creation_date", "request_description"]]
.assign(request_creation_date=lambda t: t.request_creation_date.dt.date).head(30).to_string(index=False))
request_number status request_creation_date request_description
ODR23-373 Rejected 2023-09-18 Properties Shapefile
ODR23-371 Rejected 2023-08-30 city manhole numbers
ODR23-369 Rejected 2023-08-01 Shared E-Mobility - Internal CoE
ODR23-367 Rejected 2023-06-30 Community Mailbox Locations
ODR23-363 Rejected 2023-04-27 Added data columns to Property Assessment Data (Current Calendar Year)
ODR23-362 Rejected 2023-04-25 Grocery Stores (including Convenience Stores)
ODR23-359 Declined 2023-03-20 Population Forecasts by Neighbourhood
ODR23-356 Declined 2023-03-06 LIDAR data breakdown
ODR23-352 Rejected 2023-02-15 Access to Property Sales and Transfers dataset
ODR22-351 Rejected 2022-12-05 Information discrepancy between maps and data
ODR22-343 Rejected 2022-06-22 Combined sewer overflow volume
ODR22-341 Rejected 2022-04-11 Monthly call-outs by type of purpose of dispatch at the High Level Bridge
ODR22-339 Rejected 2022-03-16 107 Avenue and 142 Street Roundabout design information
ODR21-330 Rejected 2021-11-01 Missing incidents
ODR20-314 Rejected 2020-11-11 Gas utility mapping
ODR20-313 Rejected 2020-11-11 Gas utility mapping
ODR20-312 Rejected 2020-11-11 Power utility mapping
ODR20-310 Rejected 2020-11-11 Sidewalk Snow and Ice Clearing Route Status
ODR20-306 Rejected 2020-10-02 Waste Management Recycling
ODR20-304 Rejected 2020-08-13 Concerns regarding bylaw 18590
ODR20-289 Rejected 2020-03-26 City Council Meeting Transcripts / Closed Captions
ODR20-285 Rejected 2020-02-20 Vehicle Position Log
ODR19-277 Rejected 2019-12-19 Crimes by neighborhood
ODR19-274 Rejected 2019-12-02 Area Structure Plan - GIS compatiable
ODR19-267 Rejected 2019-11-05 Lead Water Test Results
ODR19-262 Rejected 2019-08-01 Can we get city documents that are searchable from city website
ODR19-261 Rejected 2019-08-01 City Guidelines pdf copy
ODR19-260 Rejected 2019-08-01 City Plans Pdf copies
ODR19-242 Rejected 2019-03-06 Recent land title transfers
ODR19-239 Rejected 2019-02-12 Standardized data categories for each data set
5c. The open backlog¶
Requests still NEW, IN PROGRESS or PAUSED, aged from the original
request date. A moved request is counted once and dated back to its
old-system original.
orig_date = pairs.set_index("request_number").request_creation_date_orig
d["first_asked"] = d.request_number.map(orig_date).fillna(d.request_creation_date)
openq = d[(d.outcome == "Open") & ~d.moved].copy()
openq["age_years"] = ((pd.Timestamp(RUN_AT.date()) - openq.first_asked.dt.tz_localize(None)).dt.days / 365.25).round(1)
print(f"{len(openq)} open requests; median age {openq.age_years.median()} years; "
f"{(openq.age_years >= 5).sum()} first asked five or more years ago")
print(openq.status.value_counts().to_string())
print("\nOldest ten:")
print(openq.sort_values("first_asked")[["request_number", "status", "first_asked", "request_description"]]
.head(10).assign(first_asked=lambda t: t.first_asked.dt.date).to_string(index=False))
135 open requests; median age 4.0 years; 59 first asked five or more years ago
status
NEW 92
IN PROGRESS 24
PAUSED 18
New 1
Oldest ten:
request_number status first_asked request_description
8686q3fqq IN PROGRESS 2016-08-17 Drinking Fountains
8686q3h0j IN PROGRESS 2016-08-25 Request for water cost information for Edmonton
8686q3jxm NEW 2016-10-16 FOIP Requests (content)
8686q3mtu NEW 2016-10-27 311 - Daily Call Summary
8686q3qck NEW 2016-11-07 Travel Expenses for City Employees
8686q3ta4 IN PROGRESS 2017-01-23 Waterbodies in the city of Edmonton
8686q3tz0 IN PROGRESS 2017-03-04 Bridge clearance (esp. low clearance)
8686qg87v IN PROGRESS 2017-05-31 Top of Bank Spatial Data
8686qg8x6 IN PROGRESS 2017-06-27 Turf Maintenance Areas
8686qgee5 IN PROGRESS 2017-09-12 Shapefile of Herbicide-Free areas in Edmonton
5d. Asked again and again¶
Titles that appear more than once after the re-intake is removed, meaning different people asked for the same thing. Titles are grouped loosely, by a keyword such as "parcel", because requesters word the same ask differently. A cluster here is demand the City has seen repeatedly.
CLUSTERS = {
"parcel / lot polygons": r"parcel|lot (?:line|polygon|boundar)",
"property assessment extra fields / history": r"assess",
"zoning": r"zoning",
"transit ridership / ARC": r"ridership|arc card",
"crime": r"crime",
"snow": r"snow",
"trees": r"tree",
}
rows = []
for name, pat in CLUSTERS.items():
hit = u[u.request_description.fillna("").str.lower().str.contains(pat)]
rows.append((name, len(hit), hit.year.min(), hit.year.max(),
", ".join(f"{k} {v}" for k, v in hit.outcome.value_counts().items())))
display(pd.DataFrame(rows, columns=["cluster (title match)", "requests", "first", "last", "outcomes"])
.sort_values("requests", ascending=False))
| cluster (title match) | requests | first | last | outcomes | |
|---|---|---|---|---|---|
| 1 | property assessment extra fields / history | 20 | 2016 | 2026 | Closed (reason not given) 11, Refused 3, Withdrawn 2, Completed 2, Open 2 |
| 6 | trees | 10 | 2016 | 2024 | Refused 3, Completed 3, Open 2, Closed (reason not given) 2 |
| 0 | parcel / lot polygons | 9 | 2016 | 2026 | Closed (reason not given) 8, Withdrawn 1 |
| 3 | transit ridership / ARC | 8 | 2018 | 2024 | Open 8 |
| 2 | zoning | 6 | 2019 | 2025 | Closed (reason not given) 3, Open 3 |
| 4 | crime | 5 | 2016 | 2024 | Completed 2, Withdrawn 1, Refused 1, Open 1 |
| 5 | snow | 3 | 2016 | 2020 | Refused 2, Open 1 |
5e. New tracker: did a CLOSED request produce a dataset?¶
The new tracker's CLOSED doesn't say whether anything was published
(§1). The catalogue can help. For each closed request, this looks for a
portal asset created within 45 days of the close date whose name shares
at least two content words with the request title.
⚠️ This check misses a lot. Many fulfilled requests extend an existing
dataset instead of creating a new one, and requesters' titles don't match
the City's dataset names. The same check is run on old-system Completed
rows, whose outcome is known, to measure how much it misses. That catch rate
is used to scale the CLOSED figure into a rough estimate.
cat_rows, off = [], 0
while True:
page = json.loads(urllib.request.urlopen(urllib.request.Request(
"https://api.us.socrata.com/api/catalog/v1?" + urllib.parse.urlencode(
{"domains": "data.edmonton.ca", "limit": 1000, "offset": off}),
headers={"User-Agent": UA}), context=SSL_CTX, timeout=120).read())["results"]
cat_rows += [{"name": r["resource"]["name"], "created": r["resource"]["createdAt"]} for r in page]
off += 1000
if len(page) < 1000:
break
cat = pd.DataFrame(cat_rows)
cat["created"] = pd.to_datetime(cat.created).dt.tz_localize(None)
STOP = set("the of and a in to for data dataset edmonton city by on with from all at or is be "
"map maps list request information open current".split())
words = lambda s: {w for w in re.findall(r"[a-z]{3,}", str(s).lower()) if w not in STOP}
cat["words"] = cat.name.map(words)
print(f"catalogue: {len(cat):,} assets")
def catalogue_match(row):
near = cat[(cat.created - row.dataset_published_date).abs() <= pd.Timedelta(days=45)]
rw = words(row.request_description)
best = max(((len(rw & cw), n) for n, cw in zip(near.name, near.words)), default=(0, None))
return best[1] if best[0] >= 2 else None
res = {}
for label, rows in [("old Completed (known published)", d[(d.status == "Completed") & d.dataset_published_date.notna()]),
("new CLOSED (unknown)", d[d.status == "CLOSED"])]:
m = rows.apply(catalogue_match, axis=1)
res[label] = (len(rows), m.notna().sum())
if label.startswith("new"):
closed_hits = rows.assign(match=m)[m.notna()][["request_number", "request_description", "match"]]
recall = res["old Completed (known published)"][1] / res["old Completed (known published)"][0]
n_closed, h_closed = res["new CLOSED (unknown)"]
for k, (n, h) in res.items():
print(f"{k:34} {h:3} of {n:3} matched")
print(f"\ncatch rate on known-published requests: {recall:.0%}")
print(f"rough estimate of CLOSED requests that produced a new asset: {h_closed / recall:.0f} of {n_closed} "
f"(~{h_closed / recall / n_closed:.0%})")
print(closed_hits.to_string(index=False))
close_day = closed.dataset_published_date.dt.date
bulk_closed = close_day.map(close_day.value_counts()).ge(3).sum()
print(f"\nCLOSED rows whose close date is shared by 3+ closes (a bulk close, not a publish date): "
f"{bulk_closed} of {len(closed)}")
catalogue: 2,088 assets
old Completed (known published) 30 of 80 matched
new CLOSED (unknown) 6 of 152 matched
catch rate on known-published requests: 38%
rough estimate of CLOSED requests that produced a new asset: 16 of 152 (~11%)
request_number request_description match
8686dgpc7 2021 Federal Census (100% Longform) 2021 Federal Census: 25% Data (Long Form)
8686m1x37 Nodes and Corridors Nodes and Corridors
8686pk59h Waste to landfill history Waste to Landfill History
8686qg9w2 Items Due List Items Due to Edmonton City Council and Committees
868dzdxxz Aerial Ground Control Points - Geodetic Control Network Aerial Ground Control Points
868dzej26 Remote Sensing Tiling Grid Remote Sensing Tiling Grid Map
CLOSED rows whose close date is shared by 3+ closes (a bulk close, not a publish date): 57 of 152
How to read it: on the known-published old Completed requests, the
check catches only a minority. On the CLOSED side, many close dates are
bulk closes (printed above), and a 45-day window around a bulk-close day says
nothing about when an asset was made, so the check misses even more there.
The estimate is a floor. It catches a much smaller share of new
CLOSED requests. Even after scaling for what the check misses, most
CLOSED requests show no sign of having produced a new catalogue asset.
Some may have been answered by pointing to an existing dataset, which
nothing here can see. Read the matched titles rather than the percentage.
6. Duplicate check: has anyone already asked for what we are about to ask?¶
Each project ask is searched against title and details, including the
re-intake rows, so no answer is missed. The asks come from
docs/DATA_ISSUES.md. Two are dataset requests (issues 4 and 5). The
rest are defect reports about datasets that already exist, which would
not normally go through this channel. They are searched anyway, to catch
anyone who reported the same problem as a request.
⚠️ No hits means no match for these patterns on the run date. Read the near-misses before concluding that nobody asked.
OUR_ASKS = [
("Issue 4: taxable/exempt status per assessment account",
r"exempt|non-?taxable|tax code|tax status|liabilit"),
("Issue 5: school locations for private / charter / francophone operators",
r"private school|charter|francophone|independent school|school (?:site|location)"),
("Issue 1: q7d6-ambg 'Period of Coverage' year",
r"q7d6|period of coverage|current calendar year"),
("Issue 3: historical assessment roll (qi6a-xuwt) gaps",
r"qi6a|historical (?:property )?assess"),
("Issue 6: permits' neighbourhood field (24uj-dj8v)",
r"24uj|building permit.*neighbourhood|general building permit"),
("Issue 7: stt5-pzaa frozen",
r"stt5"),
("Context: other asks for more assessment fields",
r"assessment (?:data|variables|dataset)|additional fields|lot (?:size|width)"),
]
text_all = (d.request_description.fillna("") + " " + d.request_details.fillna("")).str.lower()
summary = []
for label, pat in OUR_ASKS:
hit = d[text_all.str.contains(pat, regex=True)]
summary.append((label, len(hit)))
display(Markdown(f"#### {label}\n`{pat}`: **{len(hit)} hit(s)**"))
if len(hit):
display(hit.sort_values("request_creation_date")
[["request_number", "request_creation_date", "status", "department", "request_description"]]
.assign(request_creation_date=lambda t: t.request_creation_date.dt.date)
.reset_index(drop=True))
Issue 4: taxable/exempt status per assessment account¶
exempt|non-?taxable|tax code|tax status|liabilit: 0 hit(s)
Issue 5: school locations for private / charter / francophone operators¶
private school|charter|francophone|independent school|school (?:site|location): 4 hit(s)
| request_number | request_creation_date | status | department | request_description | |
|---|---|---|---|---|---|
| 0 | ODR19-241 | 2019-03-02 | Completed | Financial Services | School Sites geospatial data |
| 1 | ODR19-263 | 2019-09-05 | Rejected | (blank) | School Site boundary For Catholic Schools |
| 2 | 8686agd98 | 2023-11-06 | CLOSED | (blank) | climate and vulnerability risk assessment |
| 3 | 8686qh58b | 2023-12-13 | CLOSED | (blank) | School Site boundary For Catholic Schools |
Issue 1: q7d6-ambg 'Period of Coverage' year¶
q7d6|period of coverage|current calendar year: 2 hit(s)
| request_number | request_creation_date | status | department | request_description | |
|---|---|---|---|---|---|
| 0 | ODR23-363 | 2023-04-27 | Rejected | (blank) | Added data columns to Property Assessment Data (Current Calendar Year) |
| 1 | 868kczkej | 2026-07-16 | CLOSED | (blank) | Year Built as of January 2010 - Data Request |
Issue 3: historical assessment roll (qi6a-xuwt) gaps¶
qi6a|historical (?:property )?assess: 3 hit(s)
| request_number | request_creation_date | status | department | request_description | |
|---|---|---|---|---|---|
| 0 | ODR16-44 | 2016-05-27 | Cancelled | Financial Services | Property Assessment from 2000-2014 |
| 1 | 8688a6366 | 2024-04-25 | CLOSED | (blank) | Historical Property Assessment Data prior to 2012. |
| 2 | 868ev2c47 | 2025-07-19 | NEW | (blank) | Historical property assessment data that includes lot width. |
Issue 6: permits' neighbourhood field (24uj-dj8v)¶
24uj|building permit.*neighbourhood|general building permit: 3 hit(s)
| request_number | request_creation_date | status | department | request_description | |
|---|---|---|---|---|---|
| 0 | ODR18-185 | 2018-03-22 | Completed | Urban Planning and Economy | Development Permits |
| 1 | ODR20-301 | 2020-07-17 | Rejected | (blank) | Adding Account Number to the General Building Permit dataset |
| 2 | 8686qhh84 | 2023-12-13 | NEW | (blank) | Adding Account Number to the General Building Permit dataset |
Issue 7: stt5-pzaa frozen¶
stt5: 0 hit(s)
Context: other asks for more assessment fields¶
assessment (?:data|variables|dataset)|additional fields|lot (?:size|width): 13 hit(s)
| request_number | request_creation_date | status | department | request_description | |
|---|---|---|---|---|---|
| 0 | ODR16-54 | 2016-06-14 | Completed | Financial Services | Property assessment data - current and historical |
| 1 | ODR19-233 | 2019-02-04 | Rejected | (blank) | Add the Land Use, Zoning & Effective Zoning to Property Assessment dataset |
| 2 | ODR19-245 | 2019-04-24 | Rejected | (blank) | Expose additional fields to the Property Assessment Data dataset. |
| 3 | 860phgmq3 | 2023-01-11 | CLOSED | (blank) | 2023 Assessment Data Release |
| 4 | ODR23-363 | 2023-04-27 | Rejected | (blank) | Added data columns to Property Assessment Data (Current Calendar Year) |
| 5 | 8686qh18m | 2023-12-13 | CLOSED | (blank) | Add the Land Use, Zoning & Effective Zoning to Property Assessment dataset |
| 6 | 8686qh3ah | 2023-12-13 | CLOSED | (blank) | Expose additional fields to the Property Assessment Data dataset. |
| 7 | 868889taf | 2024-04-19 | CLOSED | (blank) | Construction Year of Building for Assessment Data |
| 8 | 8688a6366 | 2024-04-25 | CLOSED | (blank) | Historical Property Assessment Data prior to 2012. |
| 9 | 868ev2c47 | 2025-07-19 | NEW | (blank) | Historical property assessment data that includes lot width. |
| 10 | 868ev4dbu | 2025-07-20 | NEW | (blank) | Edmonton Property Assessment Variables |
| 11 | 868fcdujr | 2025-08-27 | CLOSED | (blank) | Multi-family commercial property assessments |
| 12 | 868h7tnzc | 2026-01-25 | CLOSED | (blank) | Assessment Parcel Polygons |
What the hits mean for each ask¶
(Written against the run of 2026-09-23. Re-read the tables above when re-executing.)
- Issue 4 (exemption status): nobody has requested it. The request
would be new. The closest precedent is
ODR23-363(April 2023), which asked to add zoning, legal description and title ownership as columns on the current assessment dataset. It wasRejecteda week later, and that was a real refusal, not a migration stamp (§2a). A tax-status column is the same kind of ask, so a draft should expect the same answer and say why this one is different. The other requests in the context block run from 2016 to 2026. Most were never decided or were closed without a stated reason.868ev4dbu, which asks for a list of assessment variables, is stillNEW. - Issue 5 (schools): every earlier request was for school-site
polygons for the two public boards.
ODR19-241(public schools) was Completed. The Catholic request (ODR19-263) was never decided on the old system. It was moved in December 2023 and is nowCLOSEDas8686qh58b. Nobody has asked for private, charter or francophone operators. The request would be new, and those precedents are worth citing. - Issue 6 (permits): nobody has reported the defect. One related ask,
adding the assessment account number to the General Building Permit
dataset (
ODR20-301, never decided, moved as8686qhh84, stillNEW), would give permits a clean join key. It is worth mentioning alongside the defect report. - Issues 1, 3 and 7: nobody has reported these defects. The hits only
share wording: the dataset's own title contains "Current Calendar Year",
and the historical-assessment requests ask for years before 2012, not
for accounts missing from 2012 onward. That was expected, because defect
reports go to
opendata@edmonton.ca, not to the request form.
display(pd.DataFrame(summary, columns=["ask", "hits"]))
| ask | hits | |
|---|---|---|
| 0 | Issue 4: taxable/exempt status per assessment account | 0 |
| 1 | Issue 5: school locations for private / charter / francophone operators | 4 |
| 2 | Issue 1: q7d6-ambg 'Period of Coverage' year | 2 |
| 3 | Issue 3: historical assessment roll (qi6a-xuwt) gaps | 3 |
| 4 | Issue 6: permits' neighbourhood field (24uj-dj8v) | 3 |
| 5 | Issue 7: stt5-pzaa frozen | 0 |
| 6 | Context: other asks for more assessment fields | 13 |
Invariants¶
These guard the notebook's own readings of the data: the re-intake, the bulk-edit date, and the status mapping. If one fails, the upstream table has changed shape and the prose above is stale.
passed = sum(1 for ok, _ in CHECKS if ok)
display(Markdown(f"### {passed} of {len(CHECKS)} invariants passed"))
for ok, claim in CHECKS:
print(f" [{'PASS' if ok else 'FAIL'}] {claim}")
if passed != len(CHECKS):
raise AssertionError(f"{len(CHECKS) - passed} invariant(s) failed — see above")
10 of 10 invariants passed¶
[PASS] one page holds the whole table (656 < 50000), so no rows were cut off [PASS] request_number is unique [PASS] every status maps to an outcome bucket (unmapped: none) [PASS] all 152 CLOSED rows carry a dataset_published_date [PASS] migration pairs match one-to-one by title (126 pairs) [PASS] the Dec-2023 migration is still visible (126 pairs) [PASS] nearly every old row stamped on the migration days was moved (126 of 127), so the stamp means 'moved', not 'refused' [PASS] every migration-day stamp is Rejected/Declined, the statuses a naive reading counts as refusals [PASS] a bulk status_date sweep is present (241 rows on 2026-08-19) [PASS] the 2023 clean-up sweeps are still visible (16 rows)