The Money lens, end to end¶
This is one of the two lenses the public build exposes (the other is Development). It answers: how much assessed value, and how much municipal tax revenue, does each neighbourhood carry per acre?
How to read this notebook¶
It imports the real src/ modules and runs the same calls, in the same order,
as main.run(). It does not reimplement the pipeline — if it drifts from
production, that is a bug in this notebook, not a difference of opinion.
Two rules govern everything below, and they exist because the previous walkthrough notebook rotted silently:
- No number is written by hand. Every figure in the prose is interpolated from the run you are reading. The data refreshes weekly, so any hand-typed count is wrong within days.
- Invariants are asserted, values are not.
dropped == 50would be a weekly false alarm.aggregation preserves total assessed valuemust hold for any data, forever — so that is what gets pinned.
Checks accumulate and are reported together at the end, so one failure does not hide the rest.
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
# Resolve the repo root. nbconvert executes with cwd set to the notebook's own
# directory — which is a build directory, not the repo — so the runner passes
# the root explicitly. The cwd search is the fallback for running interactively
# in Jupyter. (`__file__` is not available in a kernel, so it is no use here.)
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))
# Input paths and the rate year come from main.py rather than being restated
# here. Restating them is exactly how a documentation notebook drifts from the
# pipeline it claims to document — it would keep passing while describing files
# production no longer reads. main.py is import-safe and puts src/ on the path.
import main # noqa: E402
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(name)s: %(message)s")
CHECKS: list[tuple[bool, str, str]] = []
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 ''}")
ASSESSMENT_CSV = main.ASSESSMENT_CSV
BOUNDARIES = main.BOUNDARIES_GEOJSON
MILL_RATES = main.MILL_RATES_JSON
ASSESSMENT_YEAR = main.ASSESSMENT_YEAR
vintage = datetime.fromtimestamp(ASSESSMENT_CSV.stat().st_mtime, timezone.utc)
print(f"repo root: {REPO_ROOT}")
print(f"assessment roll: {ASSESSMENT_CSV.name}")
print(f"data vintage: {vintage:%Y-%m-%d %H:%M UTC} (file mtime)")
print(f"mill-rate year: {ASSESSMENT_YEAR} (from main.ASSESSMENT_YEAR)")
print(f"executed: {datetime.now(timezone.utc):%Y-%m-%d %H:%M UTC}")
repo root: /home/runner/work/edmonton-tax-viz/edmonton-tax-viz assessment roll: Property_Assessment_Data__Current_Calendar_Year_.csv data vintage: 2026-10-12 17:22 UTC (file mtime) mill-rate year: 2026 (from main.ASSESSMENT_YEAR) executed: 2026-10-12 17:25 UTC
Step 1 — Load the assessment roll¶
One row per property. load_assessment normalises neighbourhood names so they
will match the boundary file later, applies the documented name corrections,
drops rows with null or zero assessed value, and flags tax-exempt rows
without removing them.
That last distinction is the one worth understanding: exclusion is a policy choice, not a data-cleaning step. Dropping a zero-value row is arithmetic hygiene — it cannot change a ratio. Removing exempt institutional land would change what the map claims, so the pipeline refuses to make that call silently. Watch the log lines below: the drop is reported as a drop, the flag as a flag.
from load_assessment import load_assessment # noqa: E402
assessment = load_assessment(str(ASSESSMENT_CSV))
print(f"\nrows: {len(assessment):,} columns: {assessment.shape[1]}")
assessment.head()
load_assessment: Flagged 2 tax-exempt rows (Assessment Class 1 == 'NONRES MUNICIPAL/RES EDUCATION')
load_assessment: Dropping 53 rows with null or zero assessed_value
rows: 439,643 columns: 13
| account_number | neighbourhood_name | assessed_value | is_exempt | tax_class | assessment_class_1 | assessment_class_2 | assessment_class_3 | assessment_class_pct_1 | assessment_class_pct_2 | assessment_class_pct_3 | latitude | longitude | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 11137148 | SECORD | 4000.0 | False | Residential | RESIDENTIAL | NaN | NaN | 100 | NaN | NaN | 53.527070 | -113.707019 |
| 1 | 11140552 | ASTER | 570000.0 | False | Residential | RESIDENTIAL | NaN | NaN | 100 | NaN | NaN | 53.450942 | -113.362159 |
| 2 | 10704838 | AMBLESIDE | 287000.0 | False | Residential | RESIDENTIAL | NaN | NaN | 100 | NaN | NaN | 53.433782 | -113.600287 |
| 3 | 4108601 | ALBERTA AVENUE | 185500.0 | False | Residential | RESIDENTIAL | NaN | NaN | 100 | NaN | NaN | 53.568288 | -113.485282 |
| 4 | 10712364 | ALLARD | 445500.0 | False | Residential | RESIDENTIAL | NaN | NaN | 100 | NaN | NaN | 53.396268 | -113.531481 |
Step 2 — Apply the mill rates¶
The municipal levy for a property is not one rate times one value. A parcel can be split across up to three assessment classes, each with its own percentage and its own rate:
$$\text{levy} = \text{assessed value} \times \sum_{\text{slot}} \frac{\text{pct}_{\text{slot}}}{100} \times \frac{\text{rate}[\text{class}_{\text{slot}}]}{1000}$$
res_levy and nonres_levy are subsets of levy — a split-class parcel
contributes only its residential slice to res_levy. They do not sum to
levy, because other rate classes (farmland, machinery) exist. That
relationship is the invariant worth pinning.
from apply_tax_rates import apply_tax_rates # noqa: E402
rates_file = json.loads(MILL_RATES.read_text())
published_years = sorted(rates_file["rates"].keys())
print(f"mill_rates.json publishes: {published_years}")
print(f"pipeline asks for: {ASSESSMENT_YEAR}\n")
check(
str(ASSESSMENT_YEAR) in rates_file["rates"],
"the rate year main.py asks for exists in mill_rates.json",
f"{ASSESSMENT_YEAR} in {published_years} — the January year-roll breaks this first",
)
assessment = apply_tax_rates(assessment, MILL_RATES, ASSESSMENT_YEAR)
levy_total = assessment["levy"].sum()
res_total = assessment["res_levy"].sum()
nonres_total = assessment["nonres_levy"].sum()
print(f"\ncitywide municipal levy: ${levy_total:,.0f}")
print(f" residential slices: ${res_total:,.0f} ({res_total / levy_total:.1%})")
print(f" non-residential: ${nonres_total:,.0f} ({nonres_total / levy_total:.1%})")
print(f" other classes: ${levy_total - res_total - nonres_total:,.0f}")
check(
res_total + nonres_total <= levy_total * 1.0000001,
"res + nonres levy never exceeds total levy",
f"${res_total + nonres_total:,.0f} <= ${levy_total:,.0f}",
)
check(levy_total > 0, "citywide levy is positive")
check(
(assessment["levy"] < 0).sum() == 0,
"no property carries a negative levy",
)
mill_rates.json publishes: ['2025', '2026'] pipeline asks for: 2026 [PASS] the rate year main.py asks for exists in mill_rates.json — 2026 in ['2025', '2026'] — the January year-roll breaks this first apply_tax_rates: 80 propert(ies) with class % not summing to 100 — billed as-stated, not normalized
apply_tax_rates: Computed municipal levy for 439643 properties (year 2026); total $2,783,390,245 (residential-class $1,448,013,937 = 52.0%, non-res-rate $1,334,836,783 = 48.0%)
citywide municipal levy: $2,783,390,245 residential slices: $1,448,013,937 (52.0%) non-residential: $1,334,836,783 (48.0%) other classes: $539,525 [PASS] res + nonres levy never exceeds total levy — $2,782,850,720 <= $2,783,390,245 [PASS] citywide levy is positive [PASS] no property carries a negative levy
Step 3 — Aggregate to neighbourhoods¶
Collapse ~440k property rows to one row per neighbourhood. This is the step
that makes the whole project a neighbourhood analysis rather than a parcel
one — and it is why docs/PARCEL_LEVEL_OPPORTUNITIES.md exists as a separate
backlog.
Aggregation is a pure sum, so it must be value-preserving. If this invariant ever fails, a neighbourhood is being dropped or double-counted.
from aggregate_by_neighbourhood import aggregate_by_neighbourhood # noqa: E402
aggregated = aggregate_by_neighbourhood(assessment)
value_before = assessment["assessed_value"].sum()
value_after = aggregated["total_assessed_value"].sum()
revenue_after = aggregated["total_revenue"].sum()
print(f"\nneighbourhoods: {len(aggregated):,}")
print(f"assessed value before ${value_before:,.0f} after ${value_after:,.0f}")
check(
abs(value_before - value_after) < 1.0,
"aggregation preserves total assessed value",
f"delta ${abs(value_before - value_after):,.4f}",
)
check(
abs(levy_total - revenue_after) < 1.0,
"aggregation preserves total levy",
f"delta ${abs(levy_total - revenue_after):,.4f}",
)
check(
aggregated["neighbourhood_name"].is_unique,
"one row per neighbourhood after aggregation",
)
aggregated.sort_values("total_assessed_value", ascending=False).head(10)
aggregate_by_neighbourhood: Aggregated 439643 properties into 407 neighbourhoods
neighbourhoods: 407 assessed value before $238,662,305,857 after $238,662,305,857 [PASS] aggregation preserves total assessed value — delta $0.0000 [PASS] aggregation preserves total levy — delta $0.0000 [PASS] one row per neighbourhood after aggregation
| neighbourhood_name | total_assessed_value | total_revenue | total_res_revenue | total_nonres_revenue | |
|---|---|---|---|---|---|
| 109 | DOWNTOWN | 7.787736e+09 | 1.463142e+08 | 2.306416e+07 | 1.232500e+08 |
| 402 | WÎHKWÊNTÔWIN | 4.198990e+09 | 4.594036e+07 | 2.776856e+07 | 1.817179e+07 |
| 397 | WINDERMERE | 3.708288e+09 | 3.276745e+07 | 2.695548e+07 | 5.804146e+06 |
| 82 | CHAPPELLE | 3.303812e+09 | 2.983873e+07 | 2.390248e+07 | 5.935647e+06 |
| 358 | SUMMERSIDE | 3.251272e+09 | 3.012595e+07 | 2.308340e+07 | 7.042552e+06 |
| 194 | KESWICK | 2.687904e+09 | 2.297987e+07 | 1.991700e+07 | 3.062660e+06 |
| 210 | LAUREL | 2.585409e+09 | 2.073266e+07 | 1.981975e+07 | 9.129046e+05 |
| 336 | SECORD | 2.376065e+09 | 2.061900e+07 | 1.759915e+07 | 3.019845e+06 |
| 377 | WALKER | 2.370254e+09 | 2.161346e+07 | 1.695026e+07 | 4.663196e+06 |
| 367 | THE ORCHARDS AT ELLERSLIE | 2.275520e+09 | 1.930221e+07 | 1.693284e+07 | 2.366926e+06 |
Step 4 — Citywide revenue share, computed before the boundary join¶
main.run() computes each neighbourhood's share of citywide revenue here,
against the full roll — deliberately, and the ordering is load-bearing.
The boundary join in step 6 can drop a neighbourhood the roll knows about. Deriving the share after that join (or in the browser, by summing the served features) would renormalise over whatever survived — so a neighbourhood's "% of city revenue" would silently depend on who else made it onto the map. Computing it here means the denominator is the real city.
city_revenue = aggregated["total_revenue"].sum()
aggregated["pct_of_city_revenue"] = (aggregated["total_revenue"] / city_revenue * 100).round(4)
top = aggregated.nlargest(5, "pct_of_city_revenue")[["neighbourhood_name", "pct_of_city_revenue"]]
print(f"citywide revenue denominator: ${city_revenue:,.0f}")
print(f"shares sum to: {aggregated['pct_of_city_revenue'].sum():.2f}%\n")
check(
abs(aggregated["pct_of_city_revenue"].sum() - 100.0) < 0.05,
"revenue shares sum to 100% across the full roll",
f"{aggregated['pct_of_city_revenue'].sum():.4f}%",
)
top
citywide revenue denominator: $2,783,390,245 shares sum to: 100.00% [PASS] revenue shares sum to 100% across the full roll — 100.0005%
| neighbourhood_name | pct_of_city_revenue | |
|---|---|---|
| 109 | DOWNTOWN | 5.2567 |
| 374 | UNIVERSITY OF ALBERTA | 1.8920 |
| 402 | WÎHKWÊNTÔWIN | 1.6505 |
| 252 | MISTATIM INDUSTRIAL | 1.3892 |
| 357 | SUMMERLEA | 1.3021 |
Step 5 — Neighbourhood boundaries and area¶
load_boundaries returns geometry plus area_acres. The CRS is set
explicitly before any area calculation — projecting after measuring would
silently produce wrong acreages, which is why the project rule says to set it
up front rather than trusting a file's declared CRS.
from load_boundaries import load_boundaries # noqa: E402
boundaries = load_boundaries(str(BOUNDARIES))
print(f"boundaries: {len(boundaries):,} CRS: {boundaries.crs}")
print(f"total area: {boundaries['area_acres'].sum():,.0f} acres")
check(boundaries.crs is not None, "boundaries carry an explicit CRS")
check(
(boundaries["area_acres"] > 0).all(),
"every boundary has positive area",
"a zero would make value_per_acre infinite",
)
boundaries[["neighbourhood_name", "area_acres"]].head()
load_boundaries: Loaded 407 boundary features (input CRS: EPSG:4326)
load_boundaries: area_acres range: 26.6 – 13180.4
boundaries: 407 CRS: EPSG:3400 total area: 193,263 acres [PASS] boundaries carry an explicit CRS [PASS] every boundary has positive area — a zero would make value_per_acre infinite
| neighbourhood_name | area_acres | |
|---|---|---|
| 0 | ABBOTTSFIELD | 101.104033 |
| 1 | ALBANY | 208.137756 |
| 2 | ALBERTA AVENUE | 414.762759 |
| 3 | ALBERTA PARK INDUSTRIAL | 164.040076 |
| 4 | ALCES | 952.916247 |
Step 6 — Join, and account for every unmatched name¶
join_and_calculate left-joins boundaries → assessment and computes
value_per_acre and revenue_per_acre. Every optional service layer is left
at None here, so this is the Money lens alone; the cost lenses that /full/
adds are a separate notebook.
No silent data drops is the project's core rule, so the point of this step
is not that the join succeeds — it is that anything unmatched is named. Watch
for the warning about the known straggler documented in data/DATA.md.
from join_and_calculate import join_and_calculate # noqa: E402
joined = join_and_calculate(aggregated, boundaries)
matched = joined["total_assessed_value"].notna().sum()
print(f"\nfeatures: {len(joined):,} with assessment: {matched:,}")
roll_names = set(aggregated["neighbourhood_name"])
boundary_names = set(boundaries["neighbourhood_name"])
in_roll_only = sorted(roll_names - boundary_names)
in_boundary_only = sorted(boundary_names - roll_names)
print(f"\nin roll but not on the map ({len(in_roll_only)}): {in_roll_only}")
print(f"on the map but not in the roll ({len(in_boundary_only)}): {len(in_boundary_only)} names")
check(
len(joined) == len(boundaries),
"the join is boundary-preserving (left join, no row multiplication)",
f"{len(joined):,} == {len(boundaries):,}",
)
check(
joined["neighbourhood_name"].is_unique,
"no boundary was duplicated by the join",
)
check(
matched > len(joined) * 0.9,
"over 90% of boundaries matched an assessment row",
f"{matched:,}/{len(joined):,} = {matched / len(joined):.1%}",
)
join_and_calculate: 1 assessment neighbourhood(s) with no boundary match (excluded from map): OLIVER
join_and_calculate: 1 boundary neighbourhood(s) with no assessment data: LEWIS FARMS
join_and_calculate: Joined 407 boundary neighbourhoods; 406 with value_per_acre calculated
features: 407 with assessment: 406 in roll but not on the map (1): ['OLIVER'] on the map but not in the roll (1): 1 names [PASS] the join is boundary-preserving (left join, no row multiplication) — 407 == 407 [PASS] no boundary was duplicated by the join [PASS] over 90% of boundaries matched an assessment row — 406/407 = 99.8%
Step 7 — The published metric, checked by hand¶
value_per_acre = total_assessed_value / area_acres. Recomputing it
independently for the top neighbourhoods is the cheapest possible guard
against a units or denominator error — the failure mode this project
actually has.
have = joined[joined["value_per_acre"].notna()].copy()
have["recomputed"] = have["total_assessed_value"] / have["area_acres"]
have["delta"] = (have["value_per_acre"] - have["recomputed"]).abs()
worst = have["delta"].max()
check(
worst < 0.01,
"value_per_acre equals total_assessed_value / area_acres for every feature",
f"largest disagreement ${worst:.6f}",
)
if "revenue_per_acre" in have.columns:
rev = have[have["revenue_per_acre"].notna()].copy()
rev_delta = (rev["revenue_per_acre"] - rev["total_revenue"] / rev["area_acres"]).abs().max()
check(
rev_delta < 0.01,
"revenue_per_acre equals total_revenue / area_acres for every feature",
f"largest disagreement ${rev_delta:.6f}",
)
cols = ["neighbourhood_name", "total_assessed_value", "area_acres", "value_per_acre"]
if "revenue_per_acre" in have.columns:
cols.append("revenue_per_acre")
have.nlargest(10, "value_per_acre")[cols]
[PASS] value_per_acre equals total_assessed_value / area_acres for every feature — largest disagreement $0.000000 [PASS] revenue_per_acre equals total_revenue / area_acres for every feature — largest disagreement $0.000000
| neighbourhood_name | total_assessed_value | area_acres | value_per_acre | revenue_per_acre | |
|---|---|---|---|---|---|
| 109 | DOWNTOWN | 7.787736e+09 | 570.634403 | 1.364751e+07 | 256406.120876 |
| 402 | WÎHKWÊNTÔWIN | 4.198990e+09 | 421.511381 | 9.961749e+06 | 108989.597411 |
| 142 | GARNEAU | 1.815364e+09 | 204.606492 | 8.872465e+06 | 86509.699840 |
| 374 | UNIVERSITY OF ALBERTA | 2.242118e+09 | 295.185819 | 7.595614e+06 | 178401.220154 |
| 357 | SUMMERLEA | 1.627544e+09 | 271.601909 | 5.992388e+06 | 133441.298853 |
| 53 | BOYLE STREET | 1.129263e+09 | 217.675919 | 5.187818e+06 | 60422.720560 |
| 352 | STRATHCONA | 1.908900e+09 | 385.835534 | 4.947443e+06 | 50172.543948 |
| 398 | WINDSOR PARK | 8.475745e+08 | 187.086054 | 4.530399e+06 | 36713.297781 |
| 222 | MAGRATH HEIGHTS | 1.281178e+09 | 303.520292 | 4.221062e+06 | 35026.068805 |
| 221 | MACTAGGART | 1.027532e+09 | 256.557412 | 4.005074e+06 | 36634.471214 |
What this notebook does not cover¶
Stated explicitly so its silence is never mistaken for a clean bill of health:
- the lot-acre denominator (
lot_acres_eligible,parcel_frac) and its cardinality guard — theineligible_pointsdrift is an open investigation; - the Development lens (building permits) — the public build's other lens;
- every cost lens that
/full/adds: services, transport, temporal, ratio; - the web export itself — setback, simplification, and the served column
contract, which
scripts/check_served_columns.pyalready guards.
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.")
15/15 invariants held [PASS] the rate year main.py asks for exists in mill_rates.json — 2026 in ['2025', '2026'] — the January year-roll breaks this first [PASS] res + nonres levy never exceeds total levy — $2,782,850,720 <= $2,783,390,245 [PASS] citywide levy is positive [PASS] no property carries a negative levy [PASS] aggregation preserves total assessed value — delta $0.0000 [PASS] aggregation preserves total levy — delta $0.0000 [PASS] one row per neighbourhood after aggregation [PASS] revenue shares sum to 100% across the full roll — 100.0005% [PASS] boundaries carry an explicit CRS [PASS] every boundary has positive area — a zero would make value_per_acre infinite [PASS] the join is boundary-preserving (left join, no row multiplication) — 407 == 407 [PASS] no boundary was duplicated by the join [PASS] over 90% of boundaries matched an assessment row — 406/407 = 99.8% [PASS] value_per_acre equals total_assessed_value / area_acres for every feature — largest disagreement $0.000000 [PASS] revenue_per_acre equals total_revenue / area_acres for every feature — largest disagreement $0.000000 All invariants held.