The main assumptions, and how big each one is¶
Methods & definitions says how each number on the map is built and lists its known limitations. This page sizes them: for each assumption a public lens makes, how much of the levy, land, homes or records it touches this week.
The same rules as the other two pages apply. No number below is typed by hand: each is recomputed from this week's data. The table is sorted by the computed size, so its order can change from week to week. The checks at the end assert that each size could be computed and that two measures of the same thing agree. They never fail because a size moved.
⚠️ The sizes are not on one scale. "30% of recent homes" and "8% of the levy" have different denominators. The ranking tells you what to read first, not which assumption matters more.
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from IPython.display import Markdown, display
# Same root resolution as 01/02: nbconvert's cwd is the build dir, so the runner
# passes the root; the cwd search is for running interactively.
def _find_root() -> Path:
env = os.environ.get("EDMONTON_REPO_ROOT")
if env:
return Path(env).resolve()
here = Path.cwd()
for p in (here, *here.parents):
if (p / "src" / "join_and_calculate.py").exists():
return p
raise RuntimeError(
"cannot locate the repo root — set EDMONTON_REPO_ROOT or run from inside the repo"
)
REPO_ROOT = _find_root()
sys.path.insert(0, str(REPO_ROOT))
import main # noqa: E402 (import-safe; puts src/ on the path)
logging.basicConfig(stream=sys.stdout, level=logging.WARNING, format="%(name)s: %(message)s")
CHECKS: list[tuple[bool, str, str]] = []
ROWS: list[dict] = []
def check(ok: bool, claim: str, detail: str = "") -> None:
"""Record an invariant. Reported together at the end; never stops the run."""
CHECKS.append((bool(ok), claim, detail))
print(f" [{'PASS' if ok else 'FAIL'}] {claim}{' — ' + detail if detail else ''}")
def row(lens: str, assumption: str, share: float, of_what: str, direction: str, where: str) -> None:
ROWS.append(dict(lens=lens, assumption=assumption, share=float(share),
of_what=of_what, direction=direction, where=where))
print(f" {share:6.1%} {lens}: {assumption}")
def md(text: str) -> None:
# A pair of bare `$` reads as TeX math delimiters in the renderer.
display(Markdown(text.replace("$", r"\$")))
WEB_DATA = REPO_ROOT / "web" / "data"
served = pd.DataFrame([f["properties"] for f in json.loads(
(WEB_DATA / "neighbourhood_value_per_acre.geojson").read_text())["features"]])
status = json.loads((WEB_DATA / "status.json").read_text())
dev = json.loads((WEB_DATA / "dev_grid.json").read_text())
temporal = json.loads((WEB_DATA / "temporal.json").read_text())
print(f"roll year / rate year: {status.get('data_year')} / {status.get('rate_year')}")
print(f"served data generated: {status.get('generated')}")
print(f"executed: {datetime.now(timezone.utc):%Y-%m-%d %H:%M UTC}")
roll year / rate year: 2026 / 2026 served data generated: 2026-10-12 executed: 2026-10-12 17:26 UTC
Loading the roll once¶
The levy-based rows need the per-property roll, the zone code each property falls in (the same point-in-polygon join the pipeline makes), and the roll's own property-information fields. These are the pipeline's loaders, not re-implementations.
import geopandas as gpd # noqa: E402
from load_assessment import load_assessment # noqa: E402
from apply_tax_rates import apply_tax_rates # noqa: E402
from revenue_by_zone import property_zone_codes # noqa: E402
from load_zoning import EXEMPT_CANDIDATE_ZONES # noqa: E402
roll = apply_tax_rates(load_assessment(str(main.ASSESSMENT_CSV)),
main.MILL_RATES_JSON, main.ASSESSMENT_YEAR)
roll["zone"] = property_zone_codes(roll, gpd.read_file(str(main.ZONING_GEOJSON)))
info = (pd.read_csv(main.PROPERTY_INFO_CSV, usecols=["Account Number", "zoning", "lot_size"])
.rename(columns={"Account Number": "account_number", "zoning": "roll_zoning"})
.drop_duplicates("account_number"))
roll = roll.merge(info, on="account_number", how="left")
LEVY, NONRES = roll["levy"].sum(), roll["nonres_levy"].sum()
print(f"properties: {len(roll):,} levy ${LEVY / 1e9:,.2f}B non-res ${NONRES / 1e9:,.2f}B")
apply_tax_rates: 80 propert(ies) with class % not summing to 100 — billed as-stated, not normalized
revenue_by_zone: 8 propert(ies) carrying $9239 of levy fell in NO zoning polygon (bucketed as 'unzoned')
properties: 439,643 levy $2.78B non-res $1.33B
Money and Glass¶
Exempt-candidate land is counted as billed. The roll computes a levy for
parcels on zoning where many properties are tax-exempt (AJ, UF, UI,
PU, PS). Edmonton publishes no per-parcel exemption status, so the map
cannot tell which of those dollars are real. Where they aren't, the figure
is overstated. The map bands the hoods where this is large
(docs/FINDINGS_exempt_institutional.md,
docs/FINDINGS_blurb_claims.md §6).
cand = roll["zone"].isin(EXEMPT_CANDIDATE_ZONES)
exempt_total = roll.loc[cand, "levy"].sum() / LEVY
exempt_nonres = roll.loc[cand, "nonres_levy"].sum() / NONRES
row("Money (non-residential)", "levy on exempt-candidate zoning is counted as paid",
exempt_nonres, "of non-residential levy", "may overstate",
"FINDINGS_blurb_claims.md §6")
row("Money (total)", "levy on exempt-candidate zoning is counted as paid",
exempt_total, "of total levy", "may overstate", "FINDINGS_exempt_institutional.md")
served_exempt = (served["rev_frac_exempt"].fillna(0) * served["total_revenue"].fillna(0)).sum() \
/ served["total_revenue"].sum()
check(abs(served_exempt - exempt_total) < 0.005,
"the served per-hood exempt share rebuilds the citywide share recomputed from the roll",
f"served {served_exempt:.2%} vs roll {exempt_total:.2%}")
16.4% Money (non-residential): levy on exempt-candidate zoning is counted as paid
8.1% Money (total): levy on exempt-candidate zoning is counted as paid
[PASS] the served per-hood exempt share rebuilds the citywide share recomputed from the roll — served 8.06% vs roll 8.06%
Zone shares come from the bylaw map, not the roll's own zoning field.
The roll carries a zoning field, but it is blank on a large share of
parcels, and those parcels sit disproportionately on institutional zones.
Reading it would halve the exempt-candidate share above. The pipeline places
every property in a bylaw polygon instead. This row sizes what the roll field
would have missed. It is not a bias on the map.
blank = roll["roll_zoning"].isna()
row("Money / Glass", "zone shares use the bylaw map; the roll's own zoning field is blank here",
roll.loc[blank, "nonres_levy"].sum() / NONRES, "of non-residential levy",
"none on the map (instrument choice)", "FINDINGS_blurb_claims.md §6")
17.7% Money / Glass: zone shares use the bylaw map; the roll's own zoning field is blank here
The lot-acre denominator counts only parcelled land. Roads and unparcelled valley drop out of it, so a hood reads higher per lot acre than per acre. The size is the unparcelled share of the median neighbourhood's land.
pf = served.loc[~served["is_set_aside"].fillna(False).astype(bool), "parcel_frac"].dropna()
row("Money (lot acres)", "the lot-acre denominator leaves out roads and unparcelled land",
1 - pf.clip(upper=1).median(), "of the median hood's land", "raises per-lot-acre figures",
"Methods §1")
30.6% Money (lot acres): the lot-acre denominator leaves out roads and unparcelled land
Set-aside neighbourhoods are grey, off the scale. Their revenue is real, but they are not ranked (Methods §3).
aside = served["is_set_aside"].fillna(False).astype(bool)
row("Money", f"{int(aside.sum())} set-aside neighbourhoods are greyed, not ranked",
served.loc[aside, "total_revenue"].sum() / served["total_revenue"].sum(),
"of total levy", "none (excluded from the scale, not the totals)", "FINDINGS_revenue_scale.md")
0.7% Money: 48 set-aside neighbourhoods are greyed, not ranked
Glass pins each account to one point. A property bigger than a grid cell puts all its dollars in one cell. The lot-acre toggle is the counterweight.
cell_m2 = main.GRID_CELL_M ** 2
big = roll["lot_size"] > cell_m2
row("Glass", f"one point per account; the lot is bigger than a {main.GRID_CELL_M:.0f} m cell",
roll.loc[big, "levy"].sum() / LEVY, "of total levy", "concentrates dollars in one cell",
"Methods §4")
32.7% Glass: one point per account; the lot is bigger than a 100 m cell
Split-class percentages are billed as stated. A few properties' class percentages do not sum to 100. The pipeline bills them as written rather than inventing where the missing share belongs.
pct = sum(pd.to_numeric(roll[f"assessment_class_pct_{i}"], errors="coerce").fillna(0) for i in (1, 2, 3))
off = (pct - 100).abs() > 0.01
row("Money", f"{int(off.sum())} properties' class percentages don't sum to 100",
roll.loc[off, "levy"].sum() / LEVY, "of total levy", "either way, billed as stated",
"src/apply_tax_rates.py")
0.3% Money: 80 properties' class percentages don't sum to 100
Development¶
The grid shows only permits with a location. The neighbourhood view counts
every permit. The grid can place only the geocoded ones. The gap is mostly
recent permits not yet geocoded, plus older apartment permits that never were,
so the grid under-places dense growth (docs/FINDINGS_blurb_claims.md F4).
cols = dev["columns"]
for window, suffix, label in (("3yr", "_3yr", "3 years"), ("5yr", "", "5 years"),
("long", "_long", "since 2009")):
cov = dev["coverage"][window]
i = cols.index("units" + suffix)
check(sum(c[i] for c in dev["cells"]) == cov["units_geocoded"],
f"grid cells hold exactly the geocoded units ({label})", f"{cov['units_geocoded']:,}")
row("Development (grid)", f"homes permitted in the last {label} with no location are off the grid"
if window != "long" else "homes permitted since 2009 with no location are off the grid",
1 - cov["units_geocoded"] / cov["units"], f"of new homes ({label})",
"grid under-places them; the neighbourhood view counts them", "FINDINGS_blurb_claims.md F4")
[PASS] grid cells hold exactly the geocoded units (3 years) — 27,489 29.9% Development (grid): homes permitted in the last 3 years with no location are off the grid [PASS] grid cells hold exactly the geocoded units (5 years) — 46,931 21.4% Development (grid): homes permitted in the last 5 years with no location are off the grid [PASS] grid cells hold exactly the geocoded units (since 2009) — 136,106 16.2% Development (grid): homes permitted since 2009 with no location are off the grid
Change over time¶
Two roll years are omitted. The historical file's 2024 and 2025 slices are
proven incomplete, so the published series skips them
(docs/SPEC_temporal.md §0).
years = temporal["years"]
span = max(years) - min(years) + 1
missing = sorted(set(range(min(years), max(years) + 1)) - set(years))
check(not set(map(int, temporal.get("defect_accounts", []))) & set(years),
"no year with a defective slice is published", f"omitted: {missing}")
row("Change over time", f"roll years {', '.join(map(str, missing))} are omitted",
len(missing) / span, f"of the {min(years)}–{max(years)} years", "changes span the gap",
"SPEC_temporal.md §0")
[PASS] no year with a defective slice is published — omitted: [2024, 2025] 13.3% Change over time: roll years 2024, 2025 are omitted
Roads¶
Arterials are left out of road metres. Every kind of development shares them, so they are not charged to the neighbourhood they cross.
from load_boundaries import load_boundaries # noqa: E402
from load_roads import load_roads # noqa: E402
roads = load_roads(str(main.ROADS_GEOJSON), load_boundaries(str(main.BOUNDARIES_GEOJSON)))
art = roads["road_m_arterial"].sum()
row("Roads", "arterial roads are left out of road metres",
art / (art + roads["road_m_total"].sum()), "of classified centreline length",
"lowers road metres where arterials run", "DECISIONS.md 2026-07-01")
27.1% Roads: arterial roads are left out of road metres
The table, largest first¶
t = pd.DataFrame(ROWS).sort_values("share", ascending=False)
check(t["share"].between(0, 1).all() and t["share"].notna().all(),
"every size computed and lies in [0, 1]", f"{len(t)} rows")
md("| size | of what | lens | assumption | effect | argued in |\n|---|---|---|---|---|---|\n" + "\n".join(
f"| **{r.share:.1%}** | {r.of_what} | {r.lens} | {r.assumption} | {r.direction} | `{r.where}` |"
for r in t.itertuples()))
[PASS] every size computed and lies in [0, 1] — 12 rows
| size | of what | lens | assumption | effect | argued in |
|---|---|---|---|---|---|
| 32.7% | of total levy | Glass | one point per account; the lot is bigger than a 100 m cell | concentrates dollars in one cell | Methods §4 |
| 30.6% | of the median hood's land | Money (lot acres) | the lot-acre denominator leaves out roads and unparcelled land | raises per-lot-acre figures | Methods §1 |
| 29.9% | of new homes (3 years) | Development (grid) | homes permitted in the last 3 years with no location are off the grid | grid under-places them; the neighbourhood view counts them | FINDINGS_blurb_claims.md F4 |
| 27.1% | of classified centreline length | Roads | arterial roads are left out of road metres | lowers road metres where arterials run | DECISIONS.md 2026-07-01 |
| 21.4% | of new homes (5 years) | Development (grid) | homes permitted in the last 5 years with no location are off the grid | grid under-places them; the neighbourhood view counts them | FINDINGS_blurb_claims.md F4 |
| 17.7% | of non-residential levy | Money / Glass | zone shares use the bylaw map; the roll's own zoning field is blank here | none on the map (instrument choice) | FINDINGS_blurb_claims.md §6 |
| 16.4% | of non-residential levy | Money (non-residential) | levy on exempt-candidate zoning is counted as paid | may overstate | FINDINGS_blurb_claims.md §6 |
| 16.2% | of new homes (since 2009) | Development (grid) | homes permitted since 2009 with no location are off the grid | grid under-places them; the neighbourhood view counts them | FINDINGS_blurb_claims.md F4 |
| 13.3% | of the 2012–2026 years | Change over time | roll years 2024, 2025 are omitted | changes span the gap | SPEC_temporal.md §0 |
| 8.1% | of total levy | Money (total) | levy on exempt-candidate zoning is counted as paid | may overstate | FINDINGS_exempt_institutional.md |
| 0.7% | of total levy | Money | 48 set-aside neighbourhoods are greyed, not ranked | none (excluded from the scale, not the totals) | FINDINGS_revenue_scale.md |
| 0.3% | of total levy | Money | 80 properties' class percentages don't sum to 100 | either way, billed as stated | src/apply_tax_rates.py |
failed = [c for c in CHECKS if not c[0]]
print(f"{len(CHECKS) - len(failed)}/{len(CHECKS)} invariants held\n")
for ok, claim, detail in CHECKS:
print(f" [{'PASS' if ok else 'FAIL'}] {claim}{' — ' + detail if detail else ''}")
if failed:
raise AssertionError(
f"{len(failed)} invariant(s) failed:\n"
+ "\n".join(f" - {claim} {detail}" for _, claim, detail in failed)
)
print("\nAll invariants held.")
6/6 invariants held [PASS] the served per-hood exempt share rebuilds the citywide share recomputed from the roll — served 8.06% vs roll 8.06% [PASS] grid cells hold exactly the geocoded units (3 years) — 27,489 [PASS] grid cells hold exactly the geocoded units (5 years) — 46,931 [PASS] grid cells hold exactly the geocoded units (since 2009) — 136,106 [PASS] no year with a defective slice is published — omitted: [2024, 2025] [PASS] every size computed and lies in [0, 1] — 12 rows All invariants held.