refactor: multi-dashboard structural migration
Some checks failed
CI / lint-and-test (pull_request) Has been cancelled

- Rename dbt project from toronto_housing to portfolio
- Restructure dbt models into domain subdirectories:
  - shared/ for cross-domain dimensions (dim_time)
  - staging/toronto/, intermediate/toronto/, marts/toronto/
- Update SQLAlchemy models for raw_toronto schema
- Add explicit cross-schema FK relationships for FactRentals
- Namespace figure factories under figures/toronto/
- Namespace notebooks under notebooks/toronto/
- Update Makefile with domain-specific targets and env loading
- Update all documentation for multi-dashboard structure

This enables adding new dashboard projects (e.g., /football, /energy)
without structural conflicts or naming collisions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 19:08:20 -05:00
parent a5d6866d63
commit 62d1a52eed
73 changed files with 1114 additions and 623 deletions

View File

@@ -0,0 +1,87 @@
version: 2
models:
- name: int_rentals__annual
description: "Rental data enriched with time and zone dimensions"
columns:
- name: rental_id
tests:
- unique
- not_null
- name: zone_code
tests:
- not_null
- name: int_neighbourhood__demographics
description: "Combined census demographics with neighbourhood attributes"
columns:
- name: neighbourhood_id
description: "Neighbourhood identifier"
tests:
- not_null
- name: census_year
description: "Census year"
tests:
- not_null
- name: income_quintile
description: "Income quintile (1-5, city-wide)"
- name: int_neighbourhood__housing
description: "Housing indicators combining census and rental data"
columns:
- name: neighbourhood_id
description: "Neighbourhood identifier"
tests:
- not_null
- name: year
description: "Reference year"
- name: rent_to_income_pct
description: "Rent as percentage of median income"
- name: is_affordable
description: "Boolean: rent <= 30% of income"
- name: int_neighbourhood__crime_summary
description: "Aggregated crime with year-over-year trends"
columns:
- name: neighbourhood_id
description: "Neighbourhood identifier"
tests:
- not_null
- name: year
description: "Statistics year"
tests:
- not_null
- name: crime_rate_per_100k
description: "Total crime rate per 100K population"
- name: yoy_change_pct
description: "Year-over-year change percentage"
- name: int_neighbourhood__amenity_scores
description: "Normalized amenities per capita and per area"
columns:
- name: neighbourhood_id
description: "Neighbourhood identifier"
tests:
- not_null
- name: year
description: "Reference year"
- name: total_amenities_per_1000
description: "Total amenities per 1000 population"
- name: amenities_per_sqkm
description: "Total amenities per square km"
- name: int_rentals__neighbourhood_allocated
description: "CMHC rental data allocated to neighbourhoods via area weights"
columns:
- name: neighbourhood_id
description: "Neighbourhood identifier"
tests:
- not_null
- name: year
description: "Survey year"
tests:
- not_null
- name: avg_rent_2bed
description: "Weighted average 2-bedroom rent"
- name: vacancy_rate
description: "Weighted average vacancy rate"

View File

@@ -0,0 +1,60 @@
-- Intermediate: Toronto CMA census statistics by year
-- Provides city-wide averages for metrics not available at neighbourhood level
-- Used when neighbourhood-level data is unavailable (e.g., median household income)
-- Grain: One row per year
with years as (
select * from {{ ref('int_year_spine') }}
),
census as (
select * from {{ ref('stg_toronto__census') }}
),
-- Census data is only available for 2016 and 2021
-- Map each analysis year to the appropriate census year
year_to_census as (
select
y.year,
case
when y.year <= 2018 then 2016
else 2021
end as census_year
from years y
),
-- Toronto CMA median household income from Statistics Canada
-- Source: Census Profile Table 98-316-X2021001
-- 2016: $65,829 (from Census Profile)
-- 2021: $84,000 (from Census Profile)
cma_income as (
select 2016 as census_year, 65829 as median_household_income union all
select 2021 as census_year, 84000 as median_household_income
),
-- City-wide aggregates from loaded neighbourhood data
city_aggregates as (
select
census_year,
sum(population) as total_population,
avg(population_density) as avg_population_density,
avg(unemployment_rate) as avg_unemployment_rate
from census
where population is not null
group by census_year
),
final as (
select
y.year,
y.census_year,
ci.median_household_income,
ca.total_population,
ca.avg_population_density,
ca.avg_unemployment_rate
from year_to_census y
left join cma_income ci on y.census_year = ci.census_year
left join city_aggregates ca on y.census_year = ca.census_year
)
select * from final

View File

@@ -0,0 +1,79 @@
-- Intermediate: Normalized amenities per 1000 population
-- Pivots amenity types and calculates per-capita metrics
-- Grain: One row per neighbourhood per year
with neighbourhoods as (
select * from {{ ref('stg_toronto__neighbourhoods') }}
),
amenities as (
select * from {{ ref('stg_toronto__amenities') }}
),
-- Aggregate amenity types
amenities_by_year as (
select
neighbourhood_id,
amenity_year as year,
sum(case when amenity_type = 'Parks' then amenity_count else 0 end) as parks_count,
sum(case when amenity_type = 'Schools' then amenity_count else 0 end) as schools_count,
sum(case when amenity_type = 'Transit Stops' then amenity_count else 0 end) as transit_count,
sum(case when amenity_type = 'Libraries' then amenity_count else 0 end) as libraries_count,
sum(case when amenity_type = 'Community Centres' then amenity_count else 0 end) as community_centres_count,
sum(case when amenity_type = 'Recreation' then amenity_count else 0 end) as recreation_count,
sum(amenity_count) as total_amenities
from amenities
group by neighbourhood_id, amenity_year
),
amenity_scores as (
select
n.neighbourhood_id,
n.neighbourhood_name,
n.geometry,
n.population,
n.land_area_sqkm,
coalesce(a.year, 2021) as year,
-- Raw counts
a.parks_count,
a.schools_count,
a.transit_count,
a.libraries_count,
a.community_centres_count,
a.recreation_count,
a.total_amenities,
-- Per 1000 population
case when n.population > 0
then round(a.parks_count::numeric / n.population * 1000, 3)
else null
end as parks_per_1000,
case when n.population > 0
then round(a.schools_count::numeric / n.population * 1000, 3)
else null
end as schools_per_1000,
case when n.population > 0
then round(a.transit_count::numeric / n.population * 1000, 3)
else null
end as transit_per_1000,
case when n.population > 0
then round(a.total_amenities::numeric / n.population * 1000, 3)
else null
end as total_amenities_per_1000,
-- Per square km
case when n.land_area_sqkm > 0
then round(a.total_amenities::numeric / n.land_area_sqkm, 2)
else null
end as amenities_per_sqkm
from neighbourhoods n
left join amenities_by_year a on n.neighbourhood_id = a.neighbourhood_id
)
select * from amenity_scores

View File

@@ -0,0 +1,83 @@
-- Intermediate: Aggregated crime by neighbourhood with YoY change
-- Pivots crime types and calculates year-over-year trends
-- Grain: One row per neighbourhood per year
with neighbourhoods as (
select * from {{ ref('stg_toronto__neighbourhoods') }}
),
crime as (
select * from {{ ref('stg_toronto__crime') }}
),
-- Aggregate crime types
crime_by_year as (
select
neighbourhood_id,
crime_year as year,
sum(incident_count) as total_incidents,
sum(case when crime_type = 'Assault' then incident_count else 0 end) as assault_count,
sum(case when crime_type = 'Auto Theft' then incident_count else 0 end) as auto_theft_count,
sum(case when crime_type = 'Break and Enter' then incident_count else 0 end) as break_enter_count,
sum(case when crime_type = 'Robbery' then incident_count else 0 end) as robbery_count,
sum(case when crime_type = 'Theft Over' then incident_count else 0 end) as theft_over_count,
sum(case when crime_type = 'Homicide' then incident_count else 0 end) as homicide_count,
avg(rate_per_100k) as avg_rate_per_100k
from crime
group by neighbourhood_id, crime_year
),
-- Add year-over-year changes
with_yoy as (
select
c.*,
lag(c.total_incidents, 1) over (
partition by c.neighbourhood_id
order by c.year
) as prev_year_incidents,
round(
(c.total_incidents - lag(c.total_incidents, 1) over (
partition by c.neighbourhood_id
order by c.year
))::numeric /
nullif(lag(c.total_incidents, 1) over (
partition by c.neighbourhood_id
order by c.year
), 0) * 100,
2
) as yoy_change_pct
from crime_by_year c
),
crime_summary as (
select
n.neighbourhood_id,
n.neighbourhood_name,
n.geometry,
n.population,
w.year,
w.total_incidents,
w.assault_count,
w.auto_theft_count,
w.break_enter_count,
w.robbery_count,
w.theft_over_count,
w.homicide_count,
w.yoy_change_pct,
-- Crime rate per 100K population (use source data avg, or calculate if population available)
coalesce(
w.avg_rate_per_100k,
case
when n.population > 0
then round(w.total_incidents::numeric / n.population * 100000, 2)
else null
end
) as crime_rate_per_100k
from neighbourhoods n
inner join with_yoy w on n.neighbourhood_id = w.neighbourhood_id
)
select * from crime_summary

View File

@@ -0,0 +1,45 @@
-- Intermediate: Combined census demographics by neighbourhood
-- Joins neighbourhoods with census data for demographic analysis
-- Grain: One row per neighbourhood per census year
with neighbourhoods as (
select * from {{ ref('stg_toronto__neighbourhoods') }}
),
census as (
select * from {{ ref('stg_toronto__census') }}
),
demographics as (
select
n.neighbourhood_id,
n.neighbourhood_name,
n.geometry,
n.land_area_sqkm,
-- Use census_year from census data, or fall back to dim_neighbourhood's year
coalesce(c.census_year, n.census_year, 2021) as census_year,
c.population,
c.population_density,
c.median_household_income,
c.average_household_income,
c.median_age,
c.unemployment_rate,
c.pct_bachelors_or_higher as education_bachelors_pct,
c.average_dwelling_value,
-- Tenure mix
c.pct_owner_occupied,
c.pct_renter_occupied,
-- Income quintile (city-wide comparison)
ntile(5) over (
partition by c.census_year
order by c.median_household_income
) as income_quintile
from neighbourhoods n
left join census c on n.neighbourhood_id = c.neighbourhood_id
)
select * from demographics

View File

@@ -0,0 +1,56 @@
-- Intermediate: Housing indicators by neighbourhood
-- Combines census housing data with allocated CMHC rental data
-- Grain: One row per neighbourhood per year
with neighbourhoods as (
select * from {{ ref('stg_toronto__neighbourhoods') }}
),
census as (
select * from {{ ref('stg_toronto__census') }}
),
allocated_rentals as (
select * from {{ ref('int_rentals__neighbourhood_allocated') }}
),
housing as (
select
n.neighbourhood_id,
n.neighbourhood_name,
n.geometry,
coalesce(r.year, c.census_year, 2021) as year,
-- Census housing metrics
c.pct_owner_occupied,
c.pct_renter_occupied,
c.average_dwelling_value,
c.median_household_income,
-- Allocated rental metrics (weighted average from CMHC zones)
r.avg_rent_2bed,
r.vacancy_rate,
-- Affordability calculations
case
when c.median_household_income > 0 and r.avg_rent_2bed > 0
then round((r.avg_rent_2bed * 12 / c.median_household_income) * 100, 2)
else null
end as rent_to_income_pct,
-- Affordability threshold (30% of income)
case
when c.median_household_income > 0 and r.avg_rent_2bed > 0
then r.avg_rent_2bed * 12 <= c.median_household_income * 0.30
else null
end as is_affordable
from neighbourhoods n
left join census c on n.neighbourhood_id = c.neighbourhood_id
left join allocated_rentals r
on n.neighbourhood_id = r.neighbourhood_id
and r.year = c.census_year
)
select * from housing

View File

@@ -0,0 +1,57 @@
-- Intermediate: Annual rental data enriched with dimensions
-- Joins rentals with time and zone dimensions for analysis
with rentals as (
select * from {{ ref('stg_cmhc__rentals') }}
),
time_dim as (
select * from {{ ref('stg_dimensions__time') }}
),
zone_dim as (
select * from {{ ref('stg_dimensions__cmhc_zones') }}
),
enriched as (
select
r.rental_id,
-- Time attributes
t.date_key,
t.full_date,
t.year,
t.month,
t.quarter,
-- Zone attributes
z.zone_key,
z.zone_code,
z.zone_name,
-- Bedroom type
r.bedroom_type,
-- Metrics
r.rental_universe,
r.avg_rent,
r.median_rent,
r.vacancy_rate,
r.availability_rate,
r.turnover_rate,
r.year_over_year_rent_change,
r.reliability_code,
-- Calculated metrics
case
when r.rental_universe > 0 and r.vacancy_rate is not null
then round(r.rental_universe * (r.vacancy_rate / 100), 0)
else null
end as vacant_units_estimate
from rentals r
inner join time_dim t on r.date_key = t.date_key
inner join zone_dim z on r.zone_key = z.zone_key
)
select * from enriched

View File

@@ -0,0 +1,73 @@
-- Intermediate: CMHC rentals allocated to neighbourhoods via area weights
-- Disaggregates zone-level rental data to neighbourhood level
-- Grain: One row per neighbourhood per year
with crosswalk as (
select * from {{ ref('stg_cmhc__zone_crosswalk') }}
),
rentals as (
select * from {{ ref('int_rentals__annual') }}
),
neighbourhoods as (
select * from {{ ref('stg_toronto__neighbourhoods') }}
),
-- Allocate rental metrics to neighbourhoods using area weights
allocated as (
select
c.neighbourhood_id,
r.year,
r.bedroom_type,
-- Weighted average rent (using area weight)
sum(r.avg_rent * c.area_weight) as weighted_avg_rent,
sum(r.median_rent * c.area_weight) as weighted_median_rent,
sum(c.area_weight) as total_weight,
-- Weighted vacancy rate
sum(r.vacancy_rate * c.area_weight) / nullif(sum(c.area_weight), 0) as vacancy_rate,
-- Weighted rental universe
sum(r.rental_universe * c.area_weight) as rental_units_estimate
from crosswalk c
inner join rentals r on c.cmhc_zone_code = r.zone_code
group by c.neighbourhood_id, r.year, r.bedroom_type
),
-- Pivot to get 2-bedroom as primary metric
pivoted as (
select
neighbourhood_id,
year,
max(case when bedroom_type = 'Two Bedroom' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_2bed,
max(case when bedroom_type = 'One Bedroom' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_1bed,
max(case when bedroom_type = 'Bachelor' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_bachelor,
max(case when bedroom_type = 'Three Bedroom +' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_3bed,
avg(vacancy_rate) as vacancy_rate,
sum(rental_units_estimate) as total_rental_units
from allocated
group by neighbourhood_id, year
),
final as (
select
n.neighbourhood_id,
n.neighbourhood_name,
n.geometry,
p.year,
round(p.avg_rent_bachelor::numeric, 2) as avg_rent_bachelor,
round(p.avg_rent_1bed::numeric, 2) as avg_rent_1bed,
round(p.avg_rent_2bed::numeric, 2) as avg_rent_2bed,
round(p.avg_rent_3bed::numeric, 2) as avg_rent_3bed,
round(p.vacancy_rate::numeric, 2) as vacancy_rate,
round(p.total_rental_units::numeric, 0) as total_rental_units
from neighbourhoods n
inner join pivoted p on n.neighbourhood_id = p.neighbourhood_id
)
select * from final

View File

@@ -0,0 +1,25 @@
-- Intermediate: Toronto CMA rental metrics by year
-- Aggregates rental data to city-wide averages by year
-- Source: StatCan CMHC data at CMA level
-- Grain: One row per year
with rentals as (
select * from {{ ref('stg_cmhc__rentals') }}
),
-- Pivot bedroom types to columns
yearly_rentals as (
select
year,
max(case when bedroom_type = 'bachelor' then avg_rent end) as avg_rent_bachelor,
max(case when bedroom_type = '1bed' then avg_rent end) as avg_rent_1bed,
max(case when bedroom_type = '2bed' then avg_rent end) as avg_rent_2bed,
max(case when bedroom_type = '3bed' then avg_rent end) as avg_rent_3bed,
-- Use 2-bedroom as standard reference
max(case when bedroom_type = '2bed' then avg_rent end) as avg_rent_standard,
max(vacancy_rate) as vacancy_rate
from rentals
group by year
)
select * from yearly_rentals

View File

@@ -0,0 +1,11 @@
-- Intermediate: Year spine for analysis
-- Creates a row for each year from 2014-2025
-- Used to drive time-series analysis across all data sources
with years as (
-- Generate years from available data sources
-- Crime data: 2014-2024, Rentals: 2019-2025
select generate_series(2014, 2025) as year
)
select year from years