diff --git a/CLAUDE.md b/CLAUDE.md index 1916ebf..b931cee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,10 +27,10 @@ Deployment is automatic via GitHub Actions on push to `main`: renders with Quart ## Architecture -- **`_quarto.yml`**: Site-level config (theme, navbar, footer, extensions). `freeze: true` means computed R outputs are cached in `_freeze/` and not re-executed unless the source changes. +- **`_quarto.yml`**: Site-level config (theme, navbar, footer, extensions). `execute: freeze: true` is set globally in `_quarto.yml`. During a full project render (`quarto render`), code is never re-executed — stored outputs in `_freeze/` are reused. Single-document renders always re-execute regardless of freeze. - **`blog/_metadata.yml`**: Defaults for all blog posts (freeze, echo/message/warning suppression, giscus comments, title-block-banner). - **`_freeze/`**: Cached execution results (JSON + figures). Committed to the repo so CI doesn't need R packages installed. -- **`_site/`**: Build output, committed to repo. +- **`_site/`**: Build output. Gitignored — not committed. - **`_extensions/`**: Quarto extensions (fontawesome, iconify, social-embeds). - **`custom_styles.scss` / `custom_styles_dark.scss`**: Theme overrides for light/dark modes on top of the cosmo Bootstrap theme. - **`data/`**: Shared datasets (geojson, csv, zip) referenced across blog posts. @@ -51,7 +51,72 @@ link-external-icon: false --- ``` -Posts use R code chunks with knitr. Common libraries: `tidyverse`, `sf`, `gt`, `ggplot2`, `cancensus`, `mountainmathHelpers`. Data paths typically reference `../../../data` relative to the post via `here()`. +Posts use R code chunks with knitr. Common libraries: `tidyverse`, `sf`, `gt`, `ggplot2`, `cancensus`, `mountainmathHelpers`, `jdawangHelpers`. Data paths typically reference `../../../data` relative to the post via `here()`. + +## Creating a new post + +1. Create the directory `blog/YYYY/post-slug/` and `index.qmd` with the frontmatter pattern above. +2. From within the post directory, initialize renv. Use `restart = FALSE` to avoid renv attempting to restart the R session (which exits a subprocess): + ```bash + cd blog/YYYY/post-slug + Rscript -e "renv::init(restart = FALSE)" + ``` +3. Install required packages: + ```bash + Rscript -e "renv::install(c('tidyverse', 'sf', ...))" + Rscript -e "renv::install_github('jdawang/jdawangHelpers')" + ``` +4. Write the post, then render from within the post directory: + ```bash + cd blog/YYYY/post-slug + quarto render index.qmd + ``` +5. Snapshot the environment: + ```bash + Rscript -e "renv::snapshot()" + ``` +6. Commit these files: + - `index.qmd` + - `renv.lock` + - `.Rprofile` + - `.gitignore` + - `renv/activate.R`, `renv/settings.json`, `renv/.gitignore` + - `_freeze/blog/YYYY/post-slug/` (generated by render) + - Any static assets (e.g. `.png` files checked in manually) + +For a complete example of post structure, see `blog/2026/zbr-two-year-review/`. + +**Inline R expressions and cache**: When `execute: cache: true` is active, chunks containing `library()` calls can be cached, which prevents packages from loading and breaks inline R expressions (`` `r expr` ``). Any post using inline R must set `execute: cache: false` in its frontmatter. Note: `freeze` (Quarto-level, caches entire document outputs in `_freeze/`, only during project renders) and `cache` (knitr-level, caches individual chunk outputs in `*_cache/`, during all renders) are distinct mechanisms. + +## jdawangHelpers package + +`jdawangHelpers` is a companion R package providing shared utilities for blog posts. Source is at GitHub: `jdawang/jdawangHelpers`. It covers: + +- **Edmonton building permit processing** — `clean_edmonton_bp_columns()`, `filter_edmonton_residential()`, `add_edmonton_project_type()`, `add_edmonton_suite_info()`, `add_edmonton_neighbourhood_type()` +- **Transit distance calculations** — `add_transit_distance()`, `add_ecdf_by_distance()` +- **ggplot2 themes** — `theme_jd()` (dark base theme, magma palette), `theme_map()`, `theme_map_dark()`. `theme_jd()` bakes in the viridis magma palette as the default discrete fill/colour scale — to use it, map a variable to `fill` or `colour` in `aes()`. Do not use `scale_fill_manual` with hardcoded colours unless intentionally overriding the palette. +- **Map layers** — `layers_map_base()` (requires `mountainmathHelpers`), `layers_transit_ecdf()` +- **GT table helpers** + +Typical pipeline in posts: + +```r +bp |> + clean_edmonton_bp_columns(crs = 4326) |> + filter_edmonton_residential() |> + add_edmonton_project_type() |> + add_edmonton_suite_info() |> + add_edmonton_neighbourhood_type() |> + add_transit_distance(transit_stops) +``` + +**Always use jdawangHelpers functions** for building permit processing, transit calculations, themes, and map layers. Do not copy code patterns from posts that predate jdawangHelpers — those implement manually what the package now handles. When in doubt, check the package source before writing new data processing code. Use `blog/2026/mli-select-edmonton/` as the canonical reference for jdawangHelpers usage. Use `blog/2026/zbr-two-year-review/` as the reference for writing style and tone. + +Install from GitHub before using in a post: + +```r +renv::install_github("jdawang/jdawangHelpers") +``` In the first R chunk, along with imports, always load the following options like the following. These set important API keys and cache paths for the cancensus and jdawangHelpers packages: @@ -114,22 +179,18 @@ cd blog/2026/nodes-corridors Rscript -e "renv::restore()" ``` -When creating a new post, always: - -- Initialize renv in its directory using `renv::init()`. -- Install required packages before trying to execute code. +See "Creating a new post" above for the full workflow including renv setup, rendering, and what to commit. If you come across an error like "[package name or function name] not found", that is most likely due to not installing the package. Install it and try again. -Before committing changes to a blog post, always run `renv::snapshot()` from within its directory to ensure the environment is saved. - ## Freeze behaviour -Because `freeze: true` is set globally, rendered outputs are stored in `_freeze/` and reused. To force re-execution of a post's R code, change into the post's directory and rerender it with the `--no-freeze` option. +With `execute: freeze: true` set globally, full project renders reuse outputs from `_freeze/` without re-executing code. Single-document renders always re-execute. To force re-execution during a project render, delete the post's freeze directory first: ```bash -cd blog/2026/nodes-corridors/ -quarto render blog/2026/nodes-corridors/index.qmd --no-freeze +rm -rf _freeze/blog/2026/nodes-corridors +cd blog/2026/nodes-corridors +quarto render index.qmd ``` The `blog/2022/election-maps-2022/index.qmd` is explicitly excluded from rendering in `_quarto.yml`. diff --git a/Containerfile b/Containerfile index 8890092..6cb3e70 100644 --- a/Containerfile +++ b/Containerfile @@ -1,6 +1,8 @@ -FROM ghcr.io/rocker-org/geospatial:4.5.2 +FROM ghcr.io/rocker-org/geospatial:4.5.3 -RUN sudo curl -sLO https://cdn.posit.co/positron/releases/deb/x86_64/Positron-2026.03.0-212-x64.deb \ +RUN sudo apt-get update \ + && sudo apt install -y curl \ + && sudo curl -sLO https://cdn.posit.co/positron/releases/deb/x86_64/Positron-2026.03.0-212-x64.deb \ && sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg \ && sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list' \ && sudo apt-get update \ @@ -14,4 +16,4 @@ RUN sudo curl -sLO https://cdn.posit.co/positron/releases/deb/x86_64/Positron-2 rustc \ && sudo curl -sS https://starship.rs/install.sh | sh -s -- -y \ && chsh -s $(which zsh) \ - && rig add 4.5.2 + && rig add 4.5.3 diff --git a/_freeze/blog/2026/mli-select-edmonton/index/execute-results/html.json b/_freeze/blog/2026/mli-select-edmonton/index/execute-results/html.json new file mode 100644 index 0000000..b75dea4 --- /dev/null +++ b/_freeze/blog/2026/mli-select-edmonton/index/execute-results/html.json @@ -0,0 +1,17 @@ +{ + "hash": "8326c93fa3cc18320247cadbb0b7f69c", + "result": { + "engine": "knitr", + "markdown": "---\ntitle: \"MLI Select and ZBR sitting in a tree, b-u-i-l-d-i-n-g\"\ndate: \"2026-04-24\"\nauthor: \"Jacob Dawang\"\ncategories: [housing, edmonton, cmhc, zoning]\ndescription: \"Edmonton's 2024 Zoning Bylaw Renewal unlocked federal affordable housing financing. An analysis of CMHC MLI Select data shows Edmonton's rezoning has already generated hundreds of mandated-affordable units.\"\nlink-external-newwindow: true\nlink-external-icon: false\nbibliography: references.bib\ndraft: false\nexecute: \n cache: false\n---\n\nIn [a recent Substack post](https://marklocki.substack.com/p/the-affordable-housing-cost-of-repealing), Mark Locki showed that Calgary's August 2024 blanket rezoning was enabling small infill projects to access CMHC's MLI Select program, a federal mortgage insurance program that, in exchange for favourable financing, requires a portion of units to be rented at affordable rates. His analysis found that 39% of all 4-8 unit building permits in Calgary's established communities were backed by MLI Select financing.\n\nEdmonton's Zoning Bylaw Renewal (ZBR), which came into force in January 2024, works through the same mechanism. And thanks to data I obtained through an Access to Information and Privacy (ATIP) request, we can now take a good guess at quantifying the interaction between ZBR and MLI Select.\n\ntl;dr: Edmonton's ZBR has already generated an estimated 480 mandated-affordable units through MLI Select financing, units that would not exist without rezoning.\n\n::: {.callout-note}\n\nI am going off my own understanding of MLI Select, but I am not super familiar with the program. Please let me know if I got anything wrong that needs correcting.\n:::\n\n## What is MLI Select?\n\nCMHC's MLI Select program provides favourable mortgage insurance to rental housing developers in exchange for commitments to affordability, energy efficiency, and accessibility. Crucially for small infill projects, it allows developers to borrow up to 95% of project costs, dramatically reducing the equity required upfront.\n\nThe financing incentives are significant: a developer at Level 3 can borrow 95% of costs over 50 years, compared to 85% over 40 years at Level 1. This is why the vast majority of projects in the Edmonton data choose Level 3, committing to 25% affordable units in exchange for the program's best terms.\n\nThe affordability threshold is 30% of median renter household income was approximately \\$1,500 per month for Edmonton's CMA in 2021, based on a median renter household income of around \\$60,000 [@statcan2021census_profile].[^1]\n\n[^1]: The exact current threshold would be set using CMHC's Area Median Household Income figures.\n\nI've been told by someone in the know that MLI Select's approval generally comes just before or just after you get a permit.\n\n## Edmonton's housing need\n\n\n\n\n\n\n\nEdmonton had 46,155 households in core housing need as of the 2021 Census, a figure that has only grown as rents have risen since then [@statcan2021core_housing].[^2] The city has been building more housing than almost anywhere else in Canada, but affordability remains a serious challenge: more homes help, but below-market rental units matter too.\n\n[^2]: Core housing need reflects households spending 30%+ of income on shelter or living in inadequate/unsuitable housing and unable to afford alternative shelter.\n\nMLI Select is one of the few tools that generates both at once. By tying favourable mortgage insurance to affordability commitments, it turns market-rate development into a vehicle for mandated-affordable housing and Edmonton's rezoning has made it accessible to a new class of projects.\n\n## MLI Select in Edmonton: the overall picture\n\nBetween 2022 and 2025, CMHC approved 977 MLI Select loans in the Edmonton CMA, covering 33,388 units. The majority were for new construction.\n\n\n::: {.cell}\n::: {.cell-output-display}\n![MLI Select approvals in Edmonton CMA, 2022–2025](index_files/figure-html/fig-mli-all-1.png){#fig-mli-all width=672}\n:::\n:::\n\n\nAs shown in @fig-mli-all, new construction approvals peaked at 233 loans in 2024, before easing in 2025. This does not mean activity is declining. MLI approval precedes building permits, so 2025 permits largely reflect 2024 MLI approvals.\n\n## The ZBR connection: small infill projects\n\nThe real story is in small projects, the 5 to 8 unit rowhomes that ZBR specifically enabled in Edmonton's mature neighbourhoods.\n\n\n::: {.cell}\n::: {.cell-output-display}\n![Small (5–8 unit) New Construction MLI Select approvals in Edmonton CMA, 2022–2025](index_files/figure-html/fig-mli-small-1.png){#fig-mli-small width=672}\n:::\n:::\n\n\n@fig-mli-small shows the numbers are striking:\n\n- **Pre-ZBR (2022–2023):** 110 small new construction MLI Select loans in total\n- **Post-ZBR (2024–2025):** 283 loans, 2.6 times more in just two years\n\nIn 2024 alone, developers secured 179 small infill MLI loans. This was the year ZBR came into force, opening up RS-zoned lots in mature neighbourhoods to 5-8 unit development. Developers, now confident their projects were permitted as-of-right, lined up federal financing.\n\nThose 2024 MLI approvals fed directly into Edmonton's 2025 building permit year, [the first year more homes were permitted in 5–8 unit rowhomes than single family homes](../zbr-two-year-review/index.qmd) in the city's history.\n\n## How many of Edmonton's new rowhomes use MLI Select?\n\n\n\nWe can cross-reference the MLI Select approvals with Edmonton building permit data to estimate the share of small rowhome projects using MLI financing.\n\nAcross 2024 and 2025, Edmonton issued building permits for approximately 789 new 5–8 unit projects. Over the same period, roughly 36% of new small rowhome projects were backed by MLI Select financing, comparable to Mark Locki's finding of 39% for Calgary.\n\nThis is necessarily an approximation: the MLI data covers the Edmonton CMA (including surrounding municipalities) while building permits cover Edmonton city only, and the lag between MLI approval and permit issuance varies by project.\n\n## Estimating affordable units created\n\n\n::: {.cell}\n\n:::\n\n\nThe most important implication of MLI Select's prevalence among small infill projects is the mandated affordable units it generates. Across all four years, small infill MLI Select projects generated an estimated 666 mandated-affordable units in the Edmonton CMA, with approximately 480 coming in the ZBR era (2024–2025), a period that also saw 2.6 times as many projects as the two years prior (see @fig-affordable-units and @tbl-affordable).\n\nThese are units where rents are contractually capped at roughly \\$1,500 per month or below.\n\n\n::: {.cell}\n::: {.cell-output-display}\n![Estimated mandated-affordable units from small infill MLI Select projects, Edmonton CMA](index_files/figure-html/fig-affordable-units-1.png){#fig-affordable-units width=672}\n:::\n:::\n\n\n\n::: {#tbl-affordable .cell tbl-cap='Estimated affordable units from small infill MLI Select'}\n::: {.cell-output-display}\n\n```{=html}\n
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n\n \n\n\n\n\n \n\n\n\n\n \n\n\n\n\n \n\n\n\n\n \n \n \n \n \n \n
Estimated affordable units from small infill MLI Select, by year
YearMLI Select loansTotal unitsEst. affordable unitsAffordable share
2022171222924%
20239364015725%
20241791,23030124%
202510472317925%
Total3932,71566625%
Jacob Dawang, CMHC, City of Edmonton Open Data
\n
\n```\n\n:::\n:::\n\n\n## Conclusion\n\nSmall infill is only one part of Edmonton's MLI Select story, but it is uniquely enabled by zoning. Large apartment towers were already generally permitted in the areas that they are today before ZBR. The 5-8 unit rowhomes could not exist in mature neighbourhoods until January 2024. A lot of the 283 small new construction MLI Select loans approved since ZBR represents a project that would have been illegal two years earlier.\n\nEdmonton's Zoning Bylaw Renewal is delivering on housing affordability in multiple ways. By legalizing small infill development in mature neighbourhoods, the ZBR didn't just add housing supply, it unlocked CMHC's MLI Select program for a class of projects that previously couldn't exist. Those projects come with mandated affordable rents attached.\n\nThis is what good housing policy looks like: a municipal zoning reform enabling federal affordable housing finance, delivering affordable homes in mature neighbourhoods with established street grids, amenities, and transit connections where demand is high.", + "supporting": [ + "index_files" + ], + "filters": [ + "rmarkdown/pagebreak.lua" + ], + "includes": {}, + "engineDependencies": {}, + "preserve": {}, + "postProcess": true + } +} \ No newline at end of file diff --git a/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-affordable-units-1.png b/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-affordable-units-1.png new file mode 100644 index 0000000..d2f5873 Binary files /dev/null and b/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-affordable-units-1.png differ diff --git a/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-mli-all-1.png b/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-mli-all-1.png new file mode 100644 index 0000000..7db4f6d Binary files /dev/null and b/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-mli-all-1.png differ diff --git a/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-mli-small-1.png b/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-mli-small-1.png new file mode 100644 index 0000000..65bc306 Binary files /dev/null and b/_freeze/blog/2026/mli-select-edmonton/index/figure-html/fig-mli-small-1.png differ diff --git a/blog/2026/mli-select-edmonton/.Rprofile b/blog/2026/mli-select-edmonton/.Rprofile new file mode 100644 index 0000000..81b960f --- /dev/null +++ b/blog/2026/mli-select-edmonton/.Rprofile @@ -0,0 +1 @@ +source("renv/activate.R") diff --git a/blog/2026/mli-select-edmonton/index.qmd b/blog/2026/mli-select-edmonton/index.qmd new file mode 100644 index 0000000..4744108 --- /dev/null +++ b/blog/2026/mli-select-edmonton/index.qmd @@ -0,0 +1,358 @@ +--- +title: "MLI Select and ZBR sitting in a tree, b-u-i-l-d-i-n-g" +date: "2026-04-24" +author: "Jacob Dawang" +categories: [housing, edmonton, cmhc, zoning] +description: "Edmonton's 2024 Zoning Bylaw Renewal unlocked federal affordable housing financing. An analysis of CMHC MLI Select data shows Edmonton's rezoning has already generated hundreds of mandated-affordable units." +link-external-newwindow: true +link-external-icon: false +bibliography: references.bib +draft: false +execute: + cache: false +--- + +In [a recent Substack post](https://marklocki.substack.com/p/the-affordable-housing-cost-of-repealing), Mark Locki showed that Calgary's August 2024 blanket rezoning was enabling small infill projects to access CMHC's MLI Select program, a federal mortgage insurance program that, in exchange for favourable financing, requires a portion of units to be rented at affordable rates. His analysis found that 39% of all 4-8 unit building permits in Calgary's established communities were backed by MLI Select financing. + +Edmonton's Zoning Bylaw Renewal (ZBR), which came into force in January 2024, works through the same mechanism. And thanks to data I obtained through an Access to Information and Privacy (ATIP) request, we can now take a good guess at quantifying the interaction between ZBR and MLI Select. + +tl;dr: Edmonton's ZBR has already generated an estimated 480 mandated-affordable units through MLI Select financing, units that would not exist without rezoning. + +::: {.callout-note} + +I am going off my own understanding of MLI Select, but I am not super familiar with the program. Please let me know if I got anything wrong that needs correcting. +::: + +## What is MLI Select? + +CMHC's MLI Select program provides favourable mortgage insurance to rental housing developers in exchange for commitments to affordability, energy efficiency, and accessibility. Crucially for small infill projects, it allows developers to borrow up to 95% of project costs, dramatically reducing the equity required upfront. + +The financing incentives are significant: a developer at Level 3 can borrow 95% of costs over 50 years, compared to 85% over 40 years at Level 1. This is why the vast majority of projects in the Edmonton data choose Level 3, committing to 25% affordable units in exchange for the program's best terms. + +The affordability threshold is 30% of median renter household income was approximately \$1,500 per month for Edmonton's CMA in 2021, based on a median renter household income of around \$60,000 [@statcan2021census_profile].[^1] + +[^1]: The exact current threshold would be set using CMHC's Area Median Household Income figures. + +I've been told by someone in the know that MLI Select's approval generally comes just before or just after you get a permit. + +## Edmonton's housing need + +```{r} +#| label: setup +#| include: false +library(tidyverse) +library(readxl) +library(sf) +library(gt) +library(jdawangHelpers) +library(mountainmathHelpers) +library(scales) +``` + +```{r} +#| label: load-mli +#| include: false +mli_raw <- read_excel( + "../../../data/AF-2025-00069 Release Package.xlsx", + sheet = 1 +) |> + rename( + year = `First Approval Year`, + product_type = `Product Type`, + loan_purpose = `Loan Purpose Group`, + total_units = `Total Units & Beds`, + affordability_level = `Affordability Level`, + cma = CMA + ) |> + mutate( + year = as.integer(year), + total_units = as.integer(total_units) + ) +``` + +```{r} +#| label: load-bp +#| include: false +bp <- read_sf( + "../../../data/General Building Permits_20260113/geo_export_204e1c7a-3fac-403d-abe1-be2272f9ce62.shp" +) |> + filter(year < 2026) |> + clean_edmonton_bp_columns() |> + filter_edmonton_residential() |> + add_edmonton_project_type() +``` + +Edmonton had `r format(46155, big.mark=",")` households in core housing need as of the 2021 Census, a figure that has only grown as rents have risen since then [@statcan2021core_housing].[^2] The city has been building more housing than almost anywhere else in Canada, but affordability remains a serious challenge: more homes help, but below-market rental units matter too. + +[^2]: Core housing need reflects households spending 30%+ of income on shelter or living in inadequate/unsuitable housing and unable to afford alternative shelter. + +MLI Select is one of the few tools that generates both at once. By tying favourable mortgage insurance to affordability commitments, it turns market-rate development into a vehicle for mandated-affordable housing and Edmonton's rezoning has made it accessible to a new class of projects. + +## MLI Select in Edmonton: the overall picture + +Between 2022 and 2025, CMHC approved 977 MLI Select loans in the Edmonton CMA, covering 33,388 units. The majority were for new construction. + +```{r} +#| label: fig-mli-all +#| fig-cap: "MLI Select approvals in Edmonton CMA, 2022–2025" +mli_summary <- mli_raw |> + group_by(year, loan_purpose) |> + summarize(n_loans = n(), total_units = sum(total_units), .groups = "drop") + +ggplot(mli_summary, aes(x = year, y = n_loans, fill = loan_purpose)) + + geom_col(position = "stack") + + scale_x_continuous(breaks = 2022:2025) + + scale_y_continuous(labels = comma) + + labs( + title = "MLI Select loan approvals in Edmonton CMA", + subtitle = "New construction has driven growth since 2023", + x = NULL, + y = "Number of loans", + caption = "Jacob Dawang, CMHC data", + fill = "Loan purpose" + ) + + theme_jd(mode = "light") + + theme(legend.position = "bottom") +``` + +As shown in @fig-mli-all, new construction approvals peaked at 233 loans in 2024, before easing in 2025. This does not mean activity is declining. MLI approval precedes building permits, so 2025 permits largely reflect 2024 MLI approvals. + +## The ZBR connection: small infill projects + +The real story is in small projects, the 5 to 8 unit rowhomes that ZBR specifically enabled in Edmonton's mature neighbourhoods. + +```{r} +#| label: fig-mli-small +#| fig-cap: "Small (5–8 unit) New Construction MLI Select approvals in Edmonton CMA, 2022–2025" +mli_small <- mli_raw |> + filter( + loan_purpose == "New Construction", + between(total_units, 5, 8) + ) |> + group_by(year, affordability_level) |> + summarize(n_loans = n(), total_units = sum(total_units), .groups = "drop") |> + mutate(affordability_level = fct_rev(affordability_level)) + +mli_small_total <- mli_small |> + group_by(year) |> + summarize(n_loans = sum(n_loans), total_units = sum(total_units)) + +n_mli_2024 <- mli_small_total$n_loans[mli_small_total$year == 2024] + +ggplot(mli_small, aes(x = year, y = n_loans, fill = affordability_level)) + + geom_col(position = "stack") + + geom_vline( + xintercept = 2023.5, + linetype = "dashed", + colour = "grey40", + linewidth = 0.6 + ) + + annotate( + "text", + x = 2023.55, + y = Inf, + label = "ZBR in force (Jan 2024)", + hjust = 0, + vjust = 1.5, + size = 3, + colour = "grey40" + ) + + scale_x_continuous(breaks = 2022:2025) + + scale_y_continuous(labels = comma) + + labs( + title = "Small infill (5–8 unit) MLI Select approvals", + subtitle = "New construction only, Edmonton CMA", + x = NULL, + y = "Number of loans", + caption = "Jacob Dawang, CMHC data", + fill = "Affordability level" + ) + + theme_jd(mode = "light") + + theme(legend.position = "bottom") +``` + +@fig-mli-small shows the numbers are striking: + +- **Pre-ZBR (2022–2023):** 110 small new construction MLI Select loans in total +- **Post-ZBR (2024–2025):** 283 loans, 2.6 times more in just two years + +In 2024 alone, developers secured `r n_mli_2024` small infill MLI loans. This was the year ZBR came into force, opening up RS-zoned lots in mature neighbourhoods to 5-8 unit development. Developers, now confident their projects were permitted as-of-right, lined up federal financing. + +Those 2024 MLI approvals fed directly into Edmonton's 2025 building permit year, [the first year more homes were permitted in 5–8 unit rowhomes than single family homes](../zbr-two-year-review/index.qmd) in the city's history. + +## How many of Edmonton's new rowhomes use MLI Select? + +```{r} +#| label: bp-ratio +#| include: false +bp_small_by_year <- bp |> + st_drop_geometry() |> + filter(project_type == "Fiveplex to Eightplex") |> + group_by(year) |> + summarize(n_permits = n(), total_units = sum(units_added)) |> + filter(year %in% c(2024, 2025)) + +mli_small_by_year <- mli_raw |> + filter( + loan_purpose == "New Construction", + between(total_units, 5, 8), + year %in% c(2024, 2025) + ) |> + group_by(year) |> + summarize(n_mli = n(), mli_units = sum(total_units)) + +agg <- bp_small_by_year |> + left_join(mli_small_by_year, by = "year") |> + summarize( + total_mli = sum(n_mli, na.rm = TRUE), + total_permits = sum(n_permits) + ) + +n_permits_agg <- agg$total_permits +mli_ratio_pct <- scales::percent( + agg$total_mli / agg$total_permits, + accuracy = 1 +) +``` + +We can cross-reference the MLI Select approvals with Edmonton building permit data to estimate the share of small rowhome projects using MLI financing. + +Across 2024 and 2025, Edmonton issued building permits for approximately `r n_permits_agg` new 5–8 unit projects. Over the same period, roughly `r mli_ratio_pct` of new small rowhome projects were backed by MLI Select financing, comparable to Mark Locki's finding of 39% for Calgary. + +This is necessarily an approximation: the MLI data covers the Edmonton CMA (including surrounding municipalities) while building permits cover Edmonton city only, and the lag between MLI approval and permit issuance varies by project. + +## Estimating affordable units created + +```{r} +#| label: affordable-units-calc +affordability_rates <- tibble( + affordability_level = c("Level 1", "Level 2", "Level 3"), + affordable_fraction = c(0.10, 0.15, 0.25) +) + +mli_small_affordable <- mli_raw |> + filter( + loan_purpose == "New Construction", + between(total_units, 5, 8) + ) |> + left_join(affordability_rates, by = "affordability_level") |> + mutate(affordable_units = total_units * affordable_fraction) |> + group_by(year) |> + summarize( + n_loans = n(), + total_units = sum(total_units), + affordable_units = sum(affordable_units), + .groups = "drop" + ) |> + mutate( + era = if_else(year < 2024, "Pre-ZBR (2022–23)", "ZBR era (2024–25)") + ) + +total_affordable <- sum(mli_small_affordable$affordable_units) +zbr_affordable <- mli_small_affordable |> + filter(era == "ZBR era (2024–25)") |> + pull(affordable_units) |> + sum() +``` + +The most important implication of MLI Select's prevalence among small infill projects is the mandated affordable units it generates. Across all four years, small infill MLI Select projects generated an estimated `r round(total_affordable)` mandated-affordable units in the Edmonton CMA, with approximately `r round(zbr_affordable)` coming in the ZBR era (2024–2025), a period that also saw 2.6 times as many projects as the two years prior (see @fig-affordable-units and @tbl-affordable). + +These are units where rents are contractually capped at roughly \$1,500 per month or below. + +```{r} +#| label: fig-affordable-units +#| fig-cap: "Estimated mandated-affordable units from small infill MLI Select projects, Edmonton CMA" +mli_small_affordable |> + select(year, total_units, affordable_units) |> + pivot_longer( + c(total_units, affordable_units), + names_to = "type", + values_to = "units" + ) |> + mutate( + type = factor( + type, + levels = c("total_units", "affordable_units"), + labels = c("Total units", "Mandated-affordable units") + ) + ) |> + ggplot(aes(x = year, y = units, fill = type)) + + geom_col(position = "identity") + + geom_vline( + xintercept = 2023.5, + linetype = "dashed", + colour = "grey40", + linewidth = 0.6 + ) + + annotate( + "text", + x = 2023.55, + y = Inf, + label = "ZBR in force", + hjust = 0, + vjust = 1.5, + size = 3, + colour = "grey40" + ) + + scale_x_continuous(breaks = 2022:2025) + + scale_y_continuous(labels = comma) + + labs( + title = "Units from small infill MLI Select projects", + subtitle = "Mandated-affordable units must be rented at ≤30% of median renter income", + x = NULL, + y = "Units", + fill = NULL, + caption = "Jacob Dawang, CMHC, City of Edmonton Open Data" + ) + + theme_jd(mode = "light") + + theme(legend.position = "bottom") +``` + +```{r} +#| label: tbl-affordable +#| tbl-cap: "Estimated affordable units from small infill MLI Select" +# Calculate totals for summary row +affordable_total <- sum(mli_small_affordable$affordable_units) +total_units_total <- sum(mli_small_affordable$total_units) +n_loans_total <- sum(mli_small_affordable$n_loans) + +mli_small_affordable |> + select(year, n_loans, total_units, affordable_units) |> + mutate( + affordable_units = round(affordable_units), + affordable_pct = affordable_units / total_units, + year = as.character(year) + ) |> + bind_rows( + tibble( + year = "Total", + n_loans = n_loans_total, + total_units = total_units_total, + affordable_units = round(affordable_total), + affordable_pct = affordable_total / total_units_total + ) + ) |> + gt(rowname_col = "year") |> + tab_stubhead("Year") |> + cols_label( + n_loans = "MLI Select loans", + total_units = "Total units", + affordable_units = "Est. affordable units", + affordable_pct = "Affordable share" + ) |> + fmt_percent(affordable_pct, decimals = 0) |> + fmt_integer(c(n_loans, total_units, affordable_units)) |> + tab_header( + "Estimated affordable units from small infill MLI Select, by year" + ) |> + opt_stylize_jd(mode = "light") |> + finalize_gt(source = "Jacob Dawang, CMHC, City of Edmonton Open Data") +``` + +## Conclusion + +Small infill is only one part of Edmonton's MLI Select story, but it is uniquely enabled by zoning. Large apartment towers were already generally permitted in the areas that they are today before ZBR. The 5-8 unit rowhomes could not exist in mature neighbourhoods until January 2024. A lot of the 283 small new construction MLI Select loans approved since ZBR represents a project that would have been illegal two years earlier. + +Edmonton's Zoning Bylaw Renewal is delivering on housing affordability in multiple ways. By legalizing small infill development in mature neighbourhoods, the ZBR didn't just add housing supply, it unlocked CMHC's MLI Select program for a class of projects that previously couldn't exist. Those projects come with mandated affordable rents attached. + +This is what good housing policy looks like: a municipal zoning reform enabling federal affordable housing finance, delivering affordable homes in mature neighbourhoods with established street grids, amenities, and transit connections where demand is high. \ No newline at end of file diff --git a/blog/2026/mli-select-edmonton/references.bib b/blog/2026/mli-select-edmonton/references.bib new file mode 100644 index 0000000..002e96d --- /dev/null +++ b/blog/2026/mli-select-edmonton/references.bib @@ -0,0 +1,17 @@ +@misc{statcan2021census_profile, + author = {{Statistics Canada}}, + title = {Census Profile, 2021 Census of Population}, + year = {2022}, + publisher = {Statistics Canada}, + address = {Ottawa}, + note = {Statistics Canada Catalogue no. 98-316-X2021001} +} + +@misc{statcan2021core_housing, + author = {{Statistics Canada}}, + title = {Core housing need, 2021 Census of Population}, + year = {2022}, + publisher = {Statistics Canada}, + address = {Ottawa}, + note = {Statistics Canada Catalogue no. 98-510-X2021001} +} diff --git a/blog/2026/mli-select-edmonton/renv.lock b/blog/2026/mli-select-edmonton/renv.lock new file mode 100644 index 0000000..f8f6236 --- /dev/null +++ b/blog/2026/mli-select-edmonton/renv.lock @@ -0,0 +1,2303 @@ +{ + "R": { + "Version": "4.5.3", + "Repositories": [ + { + "Name": "P3M", + "URL": "https://packagemanager.posit.co/cran/latest" + }, + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "DBI": { + "Package": "DBI", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "methods" + ], + "Hash": "065ae649b05f1ff66bb0c793107508f5" + }, + "ISOcodes": { + "Package": "ISOcodes", + "Version": "2025.05.18", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "e15a512e4234d113186fb7c952641707" + }, + "KernSmooth": { + "Package": "KernSmooth", + "Version": "2.23-26", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "stats" + ], + "Hash": "2fb39782c07b5ad422b0448ae83f64c4" + }, + "MASS": { + "Package": "MASS", + "Version": "7.3-65", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats", + "utils" + ], + "Hash": "a41d0fc833ea756a1136b60a437efe26" + }, + "R.methodsS3": { + "Package": "R.methodsS3", + "Version": "1.8.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "utils" + ], + "Hash": "278c286fd6e9e75d0c2e8f731ea445c8" + }, + "R.oo": { + "Package": "R.oo", + "Version": "1.27.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R.methodsS3", + "methods", + "utils" + ], + "Hash": "a94afc14a90a68dd5043ceb9c089853b" + }, + "R.utils": { + "Package": "R.utils", + "Version": "2.13.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R.methodsS3", + "R.oo", + "methods", + "tools", + "utils" + ], + "Hash": "d32373d88da809f8974d5307481862b0" + }, + "R6": { + "Package": "R6", + "Version": "2.6.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "d4335fe7207f1c01ab8c41762f5840d4" + }, + "RColorBrewer": { + "Package": "RColorBrewer", + "Version": "1.1-3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "45f0398006e83a5b10b72a90663d8d8c" + }, + "Rcpp": { + "Package": "Rcpp", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "methods", + "utils" + ], + "Hash": "60df6f11cbeffbf9d50ad0852867f6b6" + }, + "S7": { + "Package": "S7", + "Version": "0.2.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "utils" + ], + "Hash": "9da85950949574264f92f91f0e23de26" + }, + "V8": { + "Package": "V8", + "Version": "8.0.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "Rcpp", + "curl", + "jsonlite", + "utils" + ], + "Hash": "f0301ca7b867eeb5c9b586a9e400729a" + }, + "askpass": { + "Package": "askpass", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "sys" + ], + "Hash": "c39f4155b3ceb1a9a2799d700fbd4b6a" + }, + "assertthat": { + "Package": "assertthat", + "Version": "0.2.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "tools" + ], + "Hash": "50c838a310445e954bc13f26f26a6ecf" + }, + "aws.s3": { + "Package": "aws.s3", + "Version": "0.3.22", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "aws.signature", + "base64enc", + "curl", + "digest", + "httr", + "tools", + "utils", + "xml2" + ], + "Hash": "cbb6690391d3ac39dfdfb4bff7250b4e" + }, + "aws.signature": { + "Package": "aws.signature", + "Version": "0.6.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "base64enc", + "digest" + ], + "Hash": "0006bcef272aad12f78dd5a85fc7f4fc" + }, + "backports": { + "Package": "backports", + "Version": "1.5.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "e1e1b9d75c37401117b636b7ae50827a" + }, + "base64enc": { + "Package": "base64enc", + "Version": "0.1-3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "543776ae6848fde2f48ff3816d0628bc" + }, + "bigD": { + "Package": "bigD", + "Version": "0.3.1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "ad123ab90aa2fc795512f61ff6939c4a" + }, + "bit": { + "Package": "bit", + "Version": "4.6.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "ebe86ffd194564abdc895d87742d5a29" + }, + "bit64": { + "Package": "bit64", + "Version": "4.6.0-1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "bit", + "graphics", + "methods", + "stats", + "utils" + ], + "Hash": "4f572fbc586294afff277db583b9060f" + }, + "bitops": { + "Package": "bitops", + "Version": "1.0-9", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "d972ef991d58c19e6efa71b21f5e144b" + }, + "blob": { + "Package": "blob", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "methods", + "rlang", + "vctrs" + ], + "Hash": "1e0ae40898be928695fd90a80e816253" + }, + "broom": { + "Package": "broom", + "Version": "1.0.12", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "backports", + "cli", + "dplyr", + "generics", + "glue", + "lifecycle", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyr" + ], + "Hash": "6cf2f6757591ea712c57c5d7bedc3c27" + }, + "bslib": { + "Package": "bslib", + "Version": "0.10.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "base64enc", + "cachem", + "fastmap", + "grDevices", + "htmltools", + "jquerylib", + "jsonlite", + "lifecycle", + "memoise", + "mime", + "rlang", + "sass" + ], + "Hash": "d6072fb96c4868d8cb40d4071cdc472b" + }, + "cachem": { + "Package": "cachem", + "Version": "1.1.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "fastmap", + "rlang" + ], + "Hash": "cd9a672193789068eb5a2aad65a0dedf" + }, + "callr": { + "Package": "callr", + "Version": "3.7.6", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "processx", + "utils" + ], + "Hash": "d7e13f49c19103ece9e58ad2d83a7354" + }, + "cellranger": { + "Package": "cellranger", + "Version": "1.1.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "rematch", + "tibble" + ], + "Hash": "f61dbaec772ccd2e17705c1e872e9e7c" + }, + "class": { + "Package": "class", + "Version": "7.3-23", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "MASS", + "R", + "stats", + "utils" + ], + "Hash": "d0cb9cc838c3b43560bd958fc4317fdc" + }, + "classInt": { + "Package": "classInt", + "Version": "0.4-11", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "KernSmooth", + "R", + "class", + "e1071", + "grDevices", + "graphics", + "stats" + ], + "Hash": "f2af70314a63d7f025ae668f08bd933a" + }, + "cli": { + "Package": "cli", + "Version": "3.6.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "utils" + ], + "Hash": "16850760556401a2eeb27d39bd11c9cb" + }, + "clipr": { + "Package": "clipr", + "Version": "0.8.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "utils" + ], + "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" + }, + "commonmark": { + "Package": "commonmark", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "8cba62334c1088d21689d353a7e87663" + }, + "conflicted": { + "Package": "conflicted", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "memoise", + "rlang" + ], + "Hash": "bb097fccb22d156624fd07cd2894ddb6" + }, + "cpp11": { + "Package": "cpp11", + "Version": "0.5.3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "af5958631ca49510aff96d0f5c0bbb76" + }, + "crayon": { + "Package": "crayon", + "Version": "1.5.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "grDevices", + "methods", + "utils" + ], + "Hash": "859d96e65ef198fd43e82b9628d593ef" + }, + "crosstalk": { + "Package": "crosstalk", + "Version": "1.2.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R6", + "htmltools", + "jsonlite", + "lazyeval" + ], + "Hash": "8b008bc619e0bbebb5646b3d37efdad1" + }, + "crul": { + "Package": "crul", + "Version": "1.6.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R6", + "curl", + "httpcode", + "jsonlite", + "lifecycle", + "mime", + "rlang", + "urltools" + ], + "Hash": "3aeb4061f5f9c25b3621a14c8e024a48" + }, + "curl": { + "Package": "curl", + "Version": "7.0.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "aa27e963d3deccf4bade44d06b976977" + }, + "data.table": { + "Package": "data.table", + "Version": "1.18.2.1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods" + ], + "Hash": "df99cd4333ce592df5173d6ad39b5c14" + }, + "dbplyr": { + "Package": "dbplyr", + "Version": "2.5.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "DBI", + "R", + "R6", + "blob", + "cli", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "purrr", + "rlang", + "tibble", + "tidyr", + "tidyselect", + "utils", + "vctrs", + "withr" + ], + "Hash": "e38d7151c767b00b919091be4686e274" + }, + "digest": { + "Package": "digest", + "Version": "0.6.39", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "utils" + ], + "Hash": "d18028e978a88b2b16ef8d400cb49adf" + }, + "dplyr": { + "Package": "dplyr", + "Version": "1.1.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "cli", + "generics", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "rlang", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "fedd9d00c2944ff00a0e2696ccf048ec" + }, + "dtplyr": { + "Package": "dtplyr", + "Version": "1.3.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "data.table", + "dplyr", + "glue", + "lifecycle", + "rlang", + "tibble", + "tidyselect", + "vctrs" + ], + "Hash": "50bbedd3d5b6a4ddc3c3c1602456aa35" + }, + "e1071": { + "Package": "e1071", + "Version": "1.7-17", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "class", + "grDevices", + "graphics", + "methods", + "proxy", + "stats", + "utils" + ], + "Hash": "9d516dde384526d4784166f888cd2c6c" + }, + "evaluate": { + "Package": "evaluate", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "94cf2c54237f6841cee68e3ba4ab5a14" + }, + "farver": { + "Package": "farver", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "P3M", + "Hash": "680887028577f3fa2a81e410ed0d6e42" + }, + "fastmap": { + "Package": "fastmap", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "P3M", + "Hash": "aa5e1cd11c2d15497494c5292d7ffcc8" + }, + "fontawesome": { + "Package": "fontawesome", + "Version": "0.5.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "htmltools", + "rlang" + ], + "Hash": "bd1297f9b5b1fc1372d19e2c4cd82215" + }, + "forcats": { + "Package": "forcats", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "tibble" + ], + "Hash": "f884a14605c6a9eb01db676eee793ba7" + }, + "fs": { + "Package": "fs", + "Version": "1.6.6", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "methods" + ], + "Hash": "7eb1e342eee7e0a7449c49cdaa526d39" + }, + "gargle": { + "Package": "gargle", + "Version": "1.6.1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "fs", + "glue", + "httr", + "jsonlite", + "lifecycle", + "openssl", + "rappdirs", + "rlang", + "stats", + "utils", + "withr" + ], + "Hash": "99e4f3f00be5075372bb650a3e237a71" + }, + "generics": { + "Package": "generics", + "Version": "0.1.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "methods" + ], + "Hash": "4b29bf698d0c7bdb9f1e4976e7ade41d" + }, + "geodist": { + "Package": "geodist", + "Version": "0.1.1", + "Source": "Repository", + "Repository": "P3M", + "Hash": "061a2a4bfa62f488c9d73a35b9fd2939" + }, + "geojson": { + "Package": "geojson", + "Version": "0.3.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "jqr", + "jsonlite", + "lazyeval", + "magrittr", + "methods", + "protolite", + "sp" + ], + "Hash": "efc8d020709d1e1f9628a8d5362d193b" + }, + "geojsonio": { + "Package": "geojsonio", + "Version": "0.11.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "V8", + "crul", + "geojson", + "geojsonsf", + "jqr", + "jsonlite", + "magrittr", + "methods", + "readr", + "sf", + "sp" + ], + "Hash": "f0424e67a22ce09aaf89d6bc2b7f9431" + }, + "geojsonsf": { + "Package": "geojsonsf", + "Version": "2.0.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "geometries", + "jsonify", + "rapidjsonr", + "sfheaders" + ], + "Hash": "3096b36cc9ab822560d3217c068e4641" + }, + "geometries": { + "Package": "geometries", + "Version": "0.2.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "Rcpp" + ], + "Hash": "cb7b9ad43bcf8d06d5c652f510ff5509" + }, + "ggplot2": { + "Package": "ggplot2", + "Version": "4.0.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "S7", + "cli", + "grDevices", + "grid", + "gtable", + "isoband", + "lifecycle", + "rlang", + "scales", + "stats", + "vctrs", + "withr" + ], + "Hash": "1bfa14b2a1668b3569dafb0478b2636b" + }, + "glue": { + "Package": "glue", + "Version": "1.8.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "methods" + ], + "Hash": "5899f1eaa825580172bb56c08266f37c" + }, + "googledrive": { + "Package": "googledrive", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "gargle", + "glue", + "httr", + "jsonlite", + "lifecycle", + "magrittr", + "pillar", + "purrr", + "rlang", + "tibble", + "utils", + "uuid", + "vctrs", + "withr" + ], + "Hash": "292be194aa519482c4b83f488a818ba3" + }, + "googlesheets4": { + "Package": "googlesheets4", + "Version": "1.1.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cellranger", + "cli", + "curl", + "gargle", + "glue", + "googledrive", + "httr", + "ids", + "lifecycle", + "magrittr", + "methods", + "purrr", + "rematch2", + "rlang", + "tibble", + "utils", + "vctrs", + "withr" + ], + "Hash": "376adbf359b416dbb30a76a2d2051ed5" + }, + "gridExtra": { + "Package": "gridExtra", + "Version": "2.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "grDevices", + "graphics", + "grid", + "gtable", + "utils" + ], + "Hash": "7d7f283939f563670a697165b2cf5560" + }, + "gt": { + "Package": "gt", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "base64enc", + "bigD", + "bitops", + "cli", + "commonmark", + "dplyr", + "fs", + "glue", + "htmltools", + "htmlwidgets", + "juicyjuice", + "magrittr", + "markdown", + "reactable", + "rlang", + "sass", + "scales", + "tidyselect", + "vctrs", + "xml2" + ], + "Hash": "7153ac3985ed54f1a318590b73950329" + }, + "gtable": { + "Package": "gtable", + "Version": "0.3.6", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "glue", + "grid", + "lifecycle", + "rlang", + "stats" + ], + "Hash": "de949855009e2d4d0e52a844e30617ae" + }, + "gtfsio": { + "Package": "gtfsio", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "data.table", + "fs", + "jsonlite", + "utils", + "zip" + ], + "Hash": "c88ef74fb18827ab67963a8a4d52304c" + }, + "haven": { + "Package": "haven", + "Version": "2.5.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "cpp11", + "forcats", + "hms", + "lifecycle", + "methods", + "readr", + "rlang", + "tibble", + "tidyselect", + "vctrs" + ], + "Hash": "437bc7804f8ffdfcfed38d0aead6a9d7" + }, + "highr": { + "Package": "highr", + "Version": "0.11", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "xfun" + ], + "Hash": "d65ba49117ca223614f71b60d85b8ab7" + }, + "hms": { + "Package": "hms", + "Version": "1.1.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "cli", + "lifecycle", + "methods", + "pkgconfig", + "rlang", + "vctrs" + ], + "Hash": "2799ac720626cec589478b191e48b88b" + }, + "htmltools": { + "Package": "htmltools", + "Version": "0.5.9", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "base64enc", + "digest", + "fastmap", + "grDevices", + "rlang", + "utils" + ], + "Hash": "102298e238c14eb830cc4b5edd23c3e8" + }, + "htmlwidgets": { + "Package": "htmlwidgets", + "Version": "1.6.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "grDevices", + "htmltools", + "jsonlite", + "knitr", + "rmarkdown", + "yaml" + ], + "Hash": "04291cc45198225444a397606810ac37" + }, + "httpcode": { + "Package": "httpcode", + "Version": "0.3.0", + "Source": "Repository", + "Repository": "P3M", + "Hash": "13641a1c6d2cc98801b76764078e17ea" + }, + "httr": { + "Package": "httr", + "Version": "1.4.7", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "curl", + "jsonlite", + "mime", + "openssl" + ], + "Hash": "ac107251d9d9fd72f0ca8049988f1d7f" + }, + "ids": { + "Package": "ids", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "openssl", + "uuid" + ], + "Hash": "99df65cfef20e525ed38c3d2577f7190" + }, + "isoband": { + "Package": "isoband", + "Version": "0.3.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "cli", + "cpp11", + "grid", + "rlang", + "utils" + ], + "Hash": "0f9a864bbd7ce0232ad05cb76249cc1a" + }, + "jdawangHelpers": { + "Package": "jdawangHelpers", + "Version": "0.0.0.9000", + "Source": "GitHub", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteUsername": "jdawang", + "RemoteRepo": "jdawangHelpers", + "RemoteRef": "main", + "RemoteSha": "82f1c8d23af93058a270381625b601475e5116f9", + "Requirements": [ + "R", + "dplyr", + "forcats", + "ggplot2", + "gt", + "purrr", + "scales", + "sf", + "stringr", + "tidyr", + "tidytransit", + "units", + "viridis" + ], + "Hash": "a04dd9565b629da60d0c0fed9c97e5d8" + }, + "jpeg": { + "Package": "jpeg", + "Version": "0.1-11", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "c23ab23c370d1ce3a7a80d8c0bdfa105" + }, + "jqr": { + "Package": "jqr", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "lazyeval", + "magrittr" + ], + "Hash": "25aad95d788314e4b307ead16059288d" + }, + "jquerylib": { + "Package": "jquerylib", + "Version": "0.1.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "htmltools" + ], + "Hash": "5aab57a3bd297eee1c1d862735972182" + }, + "jsonify": { + "Package": "jsonify", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "rapidjsonr" + ], + "Hash": "5edb07a22dcc39e90c2b4a761f9b39db" + }, + "jsonlite": { + "Package": "jsonlite", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "methods" + ], + "Hash": "b0776f526d36d8bd4a3344a88fe165c4" + }, + "juicyjuice": { + "Package": "juicyjuice", + "Version": "0.1.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "V8" + ], + "Hash": "3bcd11943da509341838da9399e18bce" + }, + "knitr": { + "Package": "knitr", + "Version": "1.51", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "evaluate", + "highr", + "methods", + "tools", + "xfun", + "yaml" + ], + "Hash": "27682babb50f03b6eb7939ea69ec79ca" + }, + "labeling": { + "Package": "labeling", + "Version": "0.4.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "graphics", + "stats" + ], + "Hash": "b64ec208ac5bc1852b285f665d6368b3" + }, + "lattice": { + "Package": "lattice", + "Version": "0.22-7", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "stats", + "utils" + ], + "Hash": "934f30aea6442867f57a610034ab06d3" + }, + "lazyeval": { + "Package": "lazyeval", + "Version": "0.2.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "d908914ae53b04d4c0c0fd72ecc35370" + }, + "leaflet": { + "Package": "leaflet", + "Version": "2.2.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "RColorBrewer", + "crosstalk", + "htmltools", + "htmlwidgets", + "jquerylib", + "leaflet.providers", + "magrittr", + "methods", + "png", + "raster", + "rlang", + "scales", + "sf", + "stats", + "viridisLite", + "xfun" + ], + "Hash": "da8aa03b2885c2b934923e6a53fe592a" + }, + "leaflet.providers": { + "Package": "leaflet.providers", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "htmltools" + ], + "Hash": "c0b81ad9d5d932772f7a457ac398cf36" + }, + "lifecycle": { + "Package": "lifecycle", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "rlang" + ], + "Hash": "36dbfe4fba6c064db50a671a90297c85" + }, + "litedown": { + "Package": "litedown", + "Version": "0.9", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "commonmark", + "utils", + "xfun" + ], + "Hash": "6b9435273e6506fbafa5909adf4abb88" + }, + "lubridate": { + "Package": "lubridate", + "Version": "1.9.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "generics", + "methods", + "timechange" + ], + "Hash": "be38bc740fc51783a78edb5a157e4104" + }, + "magick": { + "Package": "magick", + "Version": "2.9.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "curl", + "magrittr" + ], + "Hash": "8b43f0e49eb9585e9e7faf12145ee79e" + }, + "magrittr": { + "Package": "magrittr", + "Version": "2.0.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "8b3b213aa0c54a0dec254288ccb545d1" + }, + "mapboxapi": { + "Package": "mapboxapi", + "Version": "0.6.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "aws.s3", + "curl", + "dplyr", + "geojsonsf", + "htmltools", + "httr", + "jpeg", + "jsonlite", + "leaflet", + "magick", + "png", + "protolite", + "purrr", + "raster", + "rlang", + "sf", + "slippymath", + "stringi", + "tidyr", + "units" + ], + "Hash": "8a8f8bcb89ca75e45b22e7ee88e211de" + }, + "markdown": { + "Package": "markdown", + "Version": "2.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "litedown", + "utils", + "xfun" + ], + "Hash": "b4349847250b103bbbb8bc6819c4fbca" + }, + "memoise": { + "Package": "memoise", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "cachem", + "rlang" + ], + "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" + }, + "mime": { + "Package": "mime", + "Version": "0.13", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "tools" + ], + "Hash": "0ec19f34c72fab674d8f2b4b1c6410e1" + }, + "modelr": { + "Package": "modelr", + "Version": "0.1.11", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "broom", + "magrittr", + "purrr", + "rlang", + "tibble", + "tidyr", + "tidyselect", + "vctrs" + ], + "Hash": "4f50122dc256b1b6996a4703fecea821" + }, + "mountainmathHelpers": { + "Package": "mountainmathHelpers", + "Version": "0.1.4", + "Source": "GitHub", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteUsername": "mountainMath", + "RemoteRepo": "mountainmathHelpers", + "RemoteRef": "master", + "RemoteSha": "6af7c868601c9c7434f01169bfbe7c4b3636fd9c", + "Requirements": [ + "R.utils", + "aws.s3", + "digest", + "dplyr", + "geojsonsf", + "ggplot2", + "httr", + "jsonlite", + "mapboxapi", + "readr", + "rlang", + "rmapzen", + "sf", + "slippymath" + ], + "Hash": "e1f66d091f392c503d331a80670c769a" + }, + "openssl": { + "Package": "openssl", + "Version": "2.3.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "askpass" + ], + "Hash": "d5811dade9cd3a34a9038a45f1a59bae" + }, + "pillar": { + "Package": "pillar", + "Version": "1.11.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "cli", + "glue", + "lifecycle", + "rlang", + "utf8", + "utils", + "vctrs" + ], + "Hash": "1395e64f2689ffd503657778e810cee2" + }, + "pkgconfig": { + "Package": "pkgconfig", + "Version": "2.0.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "utils" + ], + "Hash": "01f28d4278f15c76cddbea05899c5d6f" + }, + "png": { + "Package": "png", + "Version": "0.1-8", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "bd54ba8a0a5faded999a7aab6e46b374" + }, + "prettyunits": { + "Package": "prettyunits", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "6b01fc98b1e86c4f705ce9dcfd2f57c7" + }, + "processx": { + "Package": "processx", + "Version": "3.8.6", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "ps", + "utils" + ], + "Hash": "720161b280b0a35f4d1490ead2fe81d0" + }, + "progress": { + "Package": "progress", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "crayon", + "hms", + "prettyunits" + ], + "Hash": "f4625e061cb2865f111b47ff163a5ca6" + }, + "protolite": { + "Package": "protolite", + "Version": "2.3.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "Rcpp", + "jsonlite" + ], + "Hash": "ea2364e73971f7e570bffc59868e3912" + }, + "proxy": { + "Package": "proxy", + "Version": "0.4-29", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "b6c826e897b46b163c51a3b990113a21" + }, + "ps": { + "Package": "ps", + "Version": "1.9.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "utils" + ], + "Hash": "093688087b0bacce6ba2f661f36328e2" + }, + "purrr": { + "Package": "purrr", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "lifecycle", + "magrittr", + "rlang", + "vctrs" + ], + "Hash": "0154ac5b9ef4df1a7be3a54602e781cf" + }, + "ragg": { + "Package": "ragg", + "Version": "1.5.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "systemfonts", + "textshaping" + ], + "Hash": "cdb40b21711a8870f305b24226698f9f" + }, + "rapidjsonr": { + "Package": "rapidjsonr", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "P3M", + "Hash": "b6a97237ee07c7fcfe836f1b6ba882b0" + }, + "rappdirs": { + "Package": "rappdirs", + "Version": "0.3.4", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "f146a5bfc94db048309712535d4d0aee" + }, + "raster": { + "Package": "raster", + "Version": "3.6-32", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "methods", + "sp", + "terra" + ], + "Hash": "1e193fc2c727a6f83cf95d9e16024423" + }, + "reactR": { + "Package": "reactR", + "Version": "0.6.1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "htmltools" + ], + "Hash": "b8e3d93f508045812f47136c7c44c251" + }, + "reactable": { + "Package": "reactable", + "Version": "0.4.5", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "digest", + "htmltools", + "htmlwidgets", + "jsonlite", + "reactR" + ], + "Hash": "abfd6fa3794ff78116123d95845e0c32" + }, + "readr": { + "Package": "readr", + "Version": "2.1.6", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "cli", + "clipr", + "cpp11", + "crayon", + "hms", + "lifecycle", + "methods", + "rlang", + "tibble", + "tzdb", + "utils", + "vroom" + ], + "Hash": "141ebcec1bf55707751c7956a34d93db" + }, + "readxl": { + "Package": "readxl", + "Version": "1.4.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cellranger", + "cpp11", + "progress", + "tibble", + "utils" + ], + "Hash": "18948de59f05d38a44d3e5065a76b4b6" + }, + "rematch": { + "Package": "rematch", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "P3M", + "Hash": "cbff1b666c6fa6d21202f07e2318d4f1" + }, + "rematch2": { + "Package": "rematch2", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "tibble" + ], + "Hash": "76c9e04c712a05848ae7a23d2f170a40" + }, + "renv": { + "Package": "renv", + "Version": "1.0.11", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "47623f66b4e80b3b0587bc5d7b309888" + }, + "reprex": { + "Package": "reprex", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "callr", + "cli", + "clipr", + "fs", + "glue", + "knitr", + "lifecycle", + "rlang", + "rmarkdown", + "rstudioapi", + "utils", + "withr" + ], + "Hash": "97b1d5361a24d9fb588db7afe3e5bcbf" + }, + "rlang": { + "Package": "rlang", + "Version": "1.1.7", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "utils" + ], + "Hash": "34c0d101f4613098abc538b82e0d86c5" + }, + "rmapzen": { + "Package": "rmapzen", + "Version": "0.5.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "ISOcodes", + "R", + "assertthat", + "digest", + "dplyr", + "geojsonio", + "httr", + "jsonlite", + "purrr", + "sf", + "tibble", + "tidyr", + "utils" + ], + "Hash": "5f2da6eb8d2ea66342ea0799b37fb802" + }, + "rmarkdown": { + "Package": "rmarkdown", + "Version": "2.30", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "bslib", + "evaluate", + "fontawesome", + "htmltools", + "jquerylib", + "jsonlite", + "knitr", + "methods", + "tinytex", + "tools", + "utils", + "xfun", + "yaml" + ], + "Hash": "efe19db0fde0fff13cea7eec6f695021" + }, + "rstudioapi": { + "Package": "rstudioapi", + "Version": "0.18.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "d6e1ecf8e39b6d53c8acde372b2f92d1" + }, + "rvest": { + "Package": "rvest", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "glue", + "httr", + "lifecycle", + "magrittr", + "rlang", + "selectr", + "tibble", + "xml2" + ], + "Hash": "2804ddcd7737082306c559d919886546" + }, + "s2": { + "Package": "s2", + "Version": "1.1.9", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "wk" + ], + "Hash": "4e664a5d610d58f27ec196e96cdb7f6f" + }, + "sass": { + "Package": "sass", + "Version": "0.4.10", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R6", + "fs", + "htmltools", + "rappdirs", + "rlang" + ], + "Hash": "3fb78d066fb92299b1d13f6a7c9a90a8" + }, + "scales": { + "Package": "scales", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "RColorBrewer", + "cli", + "farver", + "glue", + "labeling", + "lifecycle", + "rlang", + "viridisLite" + ], + "Hash": "c5bba8f0d1df8c4b9538a40570798d9b" + }, + "selectr": { + "Package": "selectr", + "Version": "0.5-1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "R6", + "methods", + "stringr" + ], + "Hash": "fb6f4aacfcc16539458c66b698339e69" + }, + "sf": { + "Package": "sf", + "Version": "1.1-0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "DBI", + "R", + "Rcpp", + "classInt", + "grDevices", + "graphics", + "grid", + "magrittr", + "methods", + "s2", + "stats", + "tools", + "units", + "utils" + ], + "Hash": "3e61ec188b75e39ce061f6b2dee44179" + }, + "sfheaders": { + "Package": "sfheaders", + "Version": "0.4.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "geometries" + ], + "Hash": "c655cae84ce671c8aa1067cbf2ff0fe4" + }, + "slippymath": { + "Package": "slippymath", + "Version": "0.3.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "png", + "purrr", + "raster", + "stats" + ], + "Hash": "abdaf883004a4791545655d79ca3aae0" + }, + "sp": { + "Package": "sp", + "Version": "2.2-0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "lattice", + "methods", + "stats", + "utils" + ], + "Hash": "d9ff3b753db98bc50650314c8782abe6" + }, + "stringi": { + "Package": "stringi", + "Version": "1.8.7", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "stats", + "tools", + "utils" + ], + "Hash": "2b56088e23bdd58f89aebf43a0913457" + }, + "stringr": { + "Package": "stringr", + "Version": "1.6.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "stringi", + "vctrs" + ], + "Hash": "d47392652eedc68bf916657347ff2526" + }, + "sys": { + "Package": "sys", + "Version": "3.4.3", + "Source": "Repository", + "Repository": "P3M", + "Hash": "de342ebfebdbf40477d0758d05426646" + }, + "systemfonts": { + "Package": "systemfonts", + "Version": "1.3.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "base64enc", + "cpp11", + "grid", + "jsonlite", + "lifecycle", + "tools", + "utils" + ], + "Hash": "4fae513a3bb1a155943d1e9d1ae467ec" + }, + "terra": { + "Package": "terra", + "Version": "1.8-93", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "methods" + ], + "Hash": "bb3e2b0e01eeeed8c3e828faaf6c68b5" + }, + "textshaping": { + "Package": "textshaping", + "Version": "1.0.4", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cpp11", + "lifecycle", + "stats", + "stringi", + "systemfonts", + "utils" + ], + "Hash": "477d70e23914730aa7f5c5fd351f4721" + }, + "tibble": { + "Package": "tibble", + "Version": "3.3.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "lifecycle", + "magrittr", + "methods", + "pillar", + "pkgconfig", + "rlang", + "utils", + "vctrs" + ], + "Hash": "c55df870972551cac674b50cadb2d51f" + }, + "tidyr": { + "Package": "tidyr", + "Version": "1.3.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "cpp11", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "a4fa2f5876396f04814cb9d8d9ab89e9" + }, + "tidyselect": { + "Package": "tidyselect", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang", + "vctrs", + "withr" + ], + "Hash": "829f27b9c4919c16b593794a6344d6c0" + }, + "tidytransit": { + "Package": "tidytransit", + "Version": "1.8.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "data.table", + "digest", + "dplyr", + "geodist", + "gtfsio", + "hms", + "jsonlite", + "sf" + ], + "Hash": "e9781c32fd604605bb307e4638e0ed00" + }, + "tidyverse": { + "Package": "tidyverse", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "broom", + "cli", + "conflicted", + "dbplyr", + "dplyr", + "dtplyr", + "forcats", + "ggplot2", + "googledrive", + "googlesheets4", + "haven", + "hms", + "httr", + "jsonlite", + "lubridate", + "magrittr", + "modelr", + "pillar", + "purrr", + "ragg", + "readr", + "readxl", + "reprex", + "rlang", + "rstudioapi", + "rvest", + "stringr", + "tibble", + "tidyr", + "xml2" + ], + "Hash": "c328568cd14ea89a83bd4ca7f54ae07e" + }, + "timechange": { + "Package": "timechange", + "Version": "0.4.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "39c40cb1ad47a4cc384a34a22a29463f" + }, + "tinytex": { + "Package": "tinytex", + "Version": "0.58", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "xfun" + ], + "Hash": "03af3ad04817f3019de8ee5c73dab807" + }, + "triebeard": { + "Package": "triebeard", + "Version": "0.4.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "Rcpp" + ], + "Hash": "642507a148b0dd9b5620177e0a044413" + }, + "tzdb": { + "Package": "tzdb", + "Version": "0.5.0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "09e3961e87b7bafa4a9340bbb34aeda8" + }, + "units": { + "Package": "units", + "Version": "1.0-0", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp" + ], + "Hash": "3797c73fd3bde8b1601a4e1bf53557ac" + }, + "urltools": { + "Package": "urltools", + "Version": "1.7.3.1", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "Rcpp", + "methods", + "triebeard" + ], + "Hash": "0951eeef49e3df040dfc67ce75a052ac" + }, + "utf8": { + "Package": "utf8", + "Version": "1.2.6", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "d526d558be176e9ceb68c3d1e83479b7" + }, + "uuid": { + "Package": "uuid", + "Version": "1.2-2", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "528fc9e90d70a6a115e21164f37b2c64" + }, + "vctrs": { + "Package": "vctrs", + "Version": "0.7.1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang" + ], + "Hash": "c64e64d93489bb62bb8747bbb9e06c09" + }, + "viridis": { + "Package": "viridis", + "Version": "0.6.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "ggplot2", + "gridExtra", + "viridisLite" + ], + "Hash": "acd96d9fa70adeea4a5a1150609b9745" + }, + "viridisLite": { + "Package": "viridisLite", + "Version": "0.4.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "c826c7c4241b6fc89ff55aaea3fa7491" + }, + "vroom": { + "Package": "vroom", + "Version": "1.7.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "bit64", + "cli", + "cpp11", + "crayon", + "glue", + "hms", + "lifecycle", + "methods", + "progress", + "rlang", + "stats", + "tibble", + "tidyselect", + "tzdb", + "vctrs", + "withr" + ], + "Hash": "255cb6f090718455ff89cbc28cb5eea7" + }, + "withr": { + "Package": "withr", + "Version": "3.0.2", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R", + "grDevices", + "graphics" + ], + "Hash": "cc2d62c76458d425210d1eb1478b30b4" + }, + "wk": { + "Package": "wk", + "Version": "0.9.5", + "Source": "Repository", + "Repository": "P3M", + "Requirements": [ + "R" + ], + "Hash": "10fb21ed42dbd4ed9162aaab818146e3" + }, + "xfun": { + "Package": "xfun", + "Version": "0.56", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "stats", + "tools" + ], + "Hash": "f1f18d352a39c6252932da4faa029cdd" + }, + "xml2": { + "Package": "xml2", + "Version": "1.5.2", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "methods", + "rlang" + ], + "Hash": "d8e98d08a4a4d03f5052acc6e6204b99" + }, + "yaml": { + "Package": "yaml", + "Version": "2.3.12", + "Source": "Repository", + "Repository": "P3M", + "Hash": "7cd77cb32abd9220d744307e9fc94ffb" + }, + "zip": { + "Package": "zip", + "Version": "2.3.3", + "Source": "Repository", + "Repository": "P3M", + "Hash": "6ebe4b1dc74c3e50e74e316323629583" + } + } +} diff --git a/blog/2026/mli-select-edmonton/renv/.gitignore b/blog/2026/mli-select-edmonton/renv/.gitignore new file mode 100644 index 0000000..0ec0cbb --- /dev/null +++ b/blog/2026/mli-select-edmonton/renv/.gitignore @@ -0,0 +1,7 @@ +library/ +local/ +cellar/ +lock/ +python/ +sandbox/ +staging/ diff --git a/blog/2026/mli-select-edmonton/renv/activate.R b/blog/2026/mli-select-edmonton/renv/activate.R new file mode 100644 index 0000000..0eb5108 --- /dev/null +++ b/blog/2026/mli-select-edmonton/renv/activate.R @@ -0,0 +1,1305 @@ + +local({ + + # the requested version of renv + version <- "1.0.11" + attr(version, "sha") <- NULL + + # the project directory + project <- Sys.getenv("RENV_PROJECT") + if (!nzchar(project)) + project <- getwd() + + # use start-up diagnostics if enabled + diagnostics <- Sys.getenv("RENV_STARTUP_DIAGNOSTICS", unset = "FALSE") + if (diagnostics) { + start <- Sys.time() + profile <- tempfile("renv-startup-", fileext = ".Rprof") + utils::Rprof(profile) + on.exit({ + utils::Rprof(NULL) + elapsed <- signif(difftime(Sys.time(), start, units = "auto"), digits = 2L) + writeLines(sprintf("- renv took %s to run the autoloader.", format(elapsed))) + writeLines(sprintf("- Profile: %s", profile)) + print(utils::summaryRprof(profile)) + }, add = TRUE) + } + + # figure out whether the autoloader is enabled + enabled <- local({ + + # first, check config option + override <- getOption("renv.config.autoloader.enabled") + if (!is.null(override)) + return(override) + + # if we're being run in a context where R_LIBS is already set, + # don't load -- presumably we're being run as a sub-process and + # the parent process has already set up library paths for us + rcmd <- Sys.getenv("R_CMD", unset = NA) + rlibs <- Sys.getenv("R_LIBS", unset = NA) + if (!is.na(rlibs) && !is.na(rcmd)) + return(FALSE) + + # next, check environment variables + # TODO: prefer using the configuration one in the future + envvars <- c( + "RENV_CONFIG_AUTOLOADER_ENABLED", + "RENV_AUTOLOADER_ENABLED", + "RENV_ACTIVATE_PROJECT" + ) + + for (envvar in envvars) { + envval <- Sys.getenv(envvar, unset = NA) + if (!is.na(envval)) + return(tolower(envval) %in% c("true", "t", "1")) + } + + # enable by default + TRUE + + }) + + # bail if we're not enabled + if (!enabled) { + + # if we're not enabled, we might still need to manually load + # the user profile here + profile <- Sys.getenv("R_PROFILE_USER", unset = "~/.Rprofile") + if (file.exists(profile)) { + cfg <- Sys.getenv("RENV_CONFIG_USER_PROFILE", unset = "TRUE") + if (tolower(cfg) %in% c("true", "t", "1")) + sys.source(profile, envir = globalenv()) + } + + return(FALSE) + + } + + # avoid recursion + if (identical(getOption("renv.autoloader.running"), TRUE)) { + warning("ignoring recursive attempt to run renv autoloader") + return(invisible(TRUE)) + } + + # signal that we're loading renv during R startup + options(renv.autoloader.running = TRUE) + on.exit(options(renv.autoloader.running = NULL), add = TRUE) + + # signal that we've consented to use renv + options(renv.consent = TRUE) + + # load the 'utils' package eagerly -- this ensures that renv shims, which + # mask 'utils' packages, will come first on the search path + library(utils, lib.loc = .Library) + + # unload renv if it's already been loaded + if ("renv" %in% loadedNamespaces()) + unloadNamespace("renv") + + # load bootstrap tools + ansify <- function(text) { + if (renv_ansify_enabled()) + renv_ansify_enhanced(text) + else + renv_ansify_default(text) + } + + renv_ansify_enabled <- function() { + + override <- Sys.getenv("RENV_ANSIFY_ENABLED", unset = NA) + if (!is.na(override)) + return(as.logical(override)) + + pane <- Sys.getenv("RSTUDIO_CHILD_PROCESS_PANE", unset = NA) + if (identical(pane, "build")) + return(FALSE) + + testthat <- Sys.getenv("TESTTHAT", unset = "false") + if (tolower(testthat) %in% "true") + return(FALSE) + + iderun <- Sys.getenv("R_CLI_HAS_HYPERLINK_IDE_RUN", unset = "false") + if (tolower(iderun) %in% "false") + return(FALSE) + + TRUE + + } + + renv_ansify_default <- function(text) { + text + } + + renv_ansify_enhanced <- function(text) { + + # R help links + pattern <- "`\\?(renv::(?:[^`])+)`" + replacement <- "`\033]8;;ide:help:\\1\a?\\1\033]8;;\a`" + text <- gsub(pattern, replacement, text, perl = TRUE) + + # runnable code + pattern <- "`(renv::(?:[^`])+)`" + replacement <- "`\033]8;;ide:run:\\1\a\\1\033]8;;\a`" + text <- gsub(pattern, replacement, text, perl = TRUE) + + # return ansified text + text + + } + + renv_ansify_init <- function() { + + envir <- renv_envir_self() + if (renv_ansify_enabled()) + assign("ansify", renv_ansify_enhanced, envir = envir) + else + assign("ansify", renv_ansify_default, envir = envir) + + } + + `%||%` <- function(x, y) { + if (is.null(x)) y else x + } + + catf <- function(fmt, ..., appendLF = TRUE) { + + quiet <- getOption("renv.bootstrap.quiet", default = FALSE) + if (quiet) + return(invisible()) + + msg <- sprintf(fmt, ...) + cat(msg, file = stdout(), sep = if (appendLF) "\n" else "") + + invisible(msg) + + } + + header <- function(label, + ..., + prefix = "#", + suffix = "-", + n = min(getOption("width"), 78)) + { + label <- sprintf(label, ...) + n <- max(n - nchar(label) - nchar(prefix) - 2L, 8L) + if (n <= 0) + return(paste(prefix, label)) + + tail <- paste(rep.int(suffix, n), collapse = "") + paste0(prefix, " ", label, " ", tail) + + } + + heredoc <- function(text, leave = 0) { + + # remove leading, trailing whitespace + trimmed <- gsub("^\\s*\\n|\\n\\s*$", "", text) + + # split into lines + lines <- strsplit(trimmed, "\n", fixed = TRUE)[[1L]] + + # compute common indent + indent <- regexpr("[^[:space:]]", lines) + common <- min(setdiff(indent, -1L)) - leave + text <- paste(substring(lines, common), collapse = "\n") + + # substitute in ANSI links for executable renv code + ansify(text) + + } + + startswith <- function(string, prefix) { + substring(string, 1, nchar(prefix)) == prefix + } + + bootstrap <- function(version, library) { + + friendly <- renv_bootstrap_version_friendly(version) + section <- header(sprintf("Bootstrapping renv %s", friendly)) + catf(section) + + # attempt to download renv + catf("- Downloading renv ... ", appendLF = FALSE) + withCallingHandlers( + tarball <- renv_bootstrap_download(version), + error = function(err) { + catf("FAILED") + stop("failed to download:\n", conditionMessage(err)) + } + ) + catf("OK") + on.exit(unlink(tarball), add = TRUE) + + # now attempt to install + catf("- Installing renv ... ", appendLF = FALSE) + withCallingHandlers( + status <- renv_bootstrap_install(version, tarball, library), + error = function(err) { + catf("FAILED") + stop("failed to install:\n", conditionMessage(err)) + } + ) + catf("OK") + + # add empty line to break up bootstrapping from normal output + catf("") + + return(invisible()) + } + + renv_bootstrap_tests_running <- function() { + getOption("renv.tests.running", default = FALSE) + } + + renv_bootstrap_repos <- function() { + + # get CRAN repository + cran <- getOption("renv.repos.cran", "https://cloud.r-project.org") + + # check for repos override + repos <- Sys.getenv("RENV_CONFIG_REPOS_OVERRIDE", unset = NA) + if (!is.na(repos)) { + + # check for RSPM; if set, use a fallback repository for renv + rspm <- Sys.getenv("RSPM", unset = NA) + if (identical(rspm, repos)) + repos <- c(RSPM = rspm, CRAN = cran) + + return(repos) + + } + + # check for lockfile repositories + repos <- tryCatch(renv_bootstrap_repos_lockfile(), error = identity) + if (!inherits(repos, "error") && length(repos)) + return(repos) + + # retrieve current repos + repos <- getOption("repos") + + # ensure @CRAN@ entries are resolved + repos[repos == "@CRAN@"] <- cran + + # add in renv.bootstrap.repos if set + default <- c(FALLBACK = "https://cloud.r-project.org") + extra <- getOption("renv.bootstrap.repos", default = default) + repos <- c(repos, extra) + + # remove duplicates that might've snuck in + dupes <- duplicated(repos) | duplicated(names(repos)) + repos[!dupes] + + } + + renv_bootstrap_repos_lockfile <- function() { + + lockpath <- Sys.getenv("RENV_PATHS_LOCKFILE", unset = "renv.lock") + if (!file.exists(lockpath)) + return(NULL) + + lockfile <- tryCatch(renv_json_read(lockpath), error = identity) + if (inherits(lockfile, "error")) { + warning(lockfile) + return(NULL) + } + + repos <- lockfile$R$Repositories + if (length(repos) == 0) + return(NULL) + + keys <- vapply(repos, `[[`, "Name", FUN.VALUE = character(1)) + vals <- vapply(repos, `[[`, "URL", FUN.VALUE = character(1)) + names(vals) <- keys + + return(vals) + + } + + renv_bootstrap_download <- function(version) { + + sha <- attr(version, "sha", exact = TRUE) + + methods <- if (!is.null(sha)) { + + # attempting to bootstrap a development version of renv + c( + function() renv_bootstrap_download_tarball(sha), + function() renv_bootstrap_download_github(sha) + ) + + } else { + + # attempting to bootstrap a release version of renv + c( + function() renv_bootstrap_download_tarball(version), + function() renv_bootstrap_download_cran_latest(version), + function() renv_bootstrap_download_cran_archive(version) + ) + + } + + for (method in methods) { + path <- tryCatch(method(), error = identity) + if (is.character(path) && file.exists(path)) + return(path) + } + + stop("All download methods failed") + + } + + renv_bootstrap_download_impl <- function(url, destfile) { + + mode <- "wb" + + # https://bugs.r-project.org/bugzilla/show_bug.cgi?id=17715 + fixup <- + Sys.info()[["sysname"]] == "Windows" && + substring(url, 1L, 5L) == "file:" + + if (fixup) + mode <- "w+b" + + args <- list( + url = url, + destfile = destfile, + mode = mode, + quiet = TRUE + ) + + if ("headers" %in% names(formals(utils::download.file))) { + headers <- renv_bootstrap_download_custom_headers(url) + if (length(headers) && is.character(headers)) + args$headers <- headers + } + + do.call(utils::download.file, args) + + } + + renv_bootstrap_download_custom_headers <- function(url) { + + headers <- getOption("renv.download.headers") + if (is.null(headers)) + return(character()) + + if (!is.function(headers)) + stopf("'renv.download.headers' is not a function") + + headers <- headers(url) + if (length(headers) == 0L) + return(character()) + + if (is.list(headers)) + headers <- unlist(headers, recursive = FALSE, use.names = TRUE) + + ok <- + is.character(headers) && + is.character(names(headers)) && + all(nzchar(names(headers))) + + if (!ok) + stop("invocation of 'renv.download.headers' did not return a named character vector") + + headers + + } + + renv_bootstrap_download_cran_latest <- function(version) { + + spec <- renv_bootstrap_download_cran_latest_find(version) + type <- spec$type + repos <- spec$repos + + baseurl <- utils::contrib.url(repos = repos, type = type) + ext <- if (identical(type, "source")) + ".tar.gz" + else if (Sys.info()[["sysname"]] == "Windows") + ".zip" + else + ".tgz" + name <- sprintf("renv_%s%s", version, ext) + url <- paste(baseurl, name, sep = "/") + + destfile <- file.path(tempdir(), name) + status <- tryCatch( + renv_bootstrap_download_impl(url, destfile), + condition = identity + ) + + if (inherits(status, "condition")) + return(FALSE) + + # report success and return + destfile + + } + + renv_bootstrap_download_cran_latest_find <- function(version) { + + # check whether binaries are supported on this system + binary <- + getOption("renv.bootstrap.binary", default = TRUE) && + !identical(.Platform$pkgType, "source") && + !identical(getOption("pkgType"), "source") && + Sys.info()[["sysname"]] %in% c("Darwin", "Windows") + + types <- c(if (binary) "binary", "source") + + # iterate over types + repositories + for (type in types) { + for (repos in renv_bootstrap_repos()) { + + # build arguments for utils::available.packages() call + args <- list(type = type, repos = repos) + + # add custom headers if available -- note that + # utils::available.packages() will pass this to download.file() + if ("headers" %in% names(formals(utils::download.file))) { + headers <- renv_bootstrap_download_custom_headers(repos) + if (length(headers) && is.character(headers)) + args$headers <- headers + } + + # retrieve package database + db <- tryCatch( + as.data.frame( + do.call(utils::available.packages, args), + stringsAsFactors = FALSE + ), + error = identity + ) + + if (inherits(db, "error")) + next + + # check for compatible entry + entry <- db[db$Package %in% "renv" & db$Version %in% version, ] + if (nrow(entry) == 0) + next + + # found it; return spec to caller + spec <- list(entry = entry, type = type, repos = repos) + return(spec) + + } + } + + # if we got here, we failed to find renv + fmt <- "renv %s is not available from your declared package repositories" + stop(sprintf(fmt, version)) + + } + + renv_bootstrap_download_cran_archive <- function(version) { + + name <- sprintf("renv_%s.tar.gz", version) + repos <- renv_bootstrap_repos() + urls <- file.path(repos, "src/contrib/Archive/renv", name) + destfile <- file.path(tempdir(), name) + + for (url in urls) { + + status <- tryCatch( + renv_bootstrap_download_impl(url, destfile), + condition = identity + ) + + if (identical(status, 0L)) + return(destfile) + + } + + return(FALSE) + + } + + renv_bootstrap_download_tarball <- function(version) { + + # if the user has provided the path to a tarball via + # an environment variable, then use it + tarball <- Sys.getenv("RENV_BOOTSTRAP_TARBALL", unset = NA) + if (is.na(tarball)) + return() + + # allow directories + if (dir.exists(tarball)) { + name <- sprintf("renv_%s.tar.gz", version) + tarball <- file.path(tarball, name) + } + + # bail if it doesn't exist + if (!file.exists(tarball)) { + + # let the user know we weren't able to honour their request + fmt <- "- RENV_BOOTSTRAP_TARBALL is set (%s) but does not exist." + msg <- sprintf(fmt, tarball) + warning(msg) + + # bail + return() + + } + + catf("- Using local tarball '%s'.", tarball) + tarball + + } + + renv_bootstrap_github_token <- function() { + for (envvar in c("GITHUB_TOKEN", "GITHUB_PAT", "GH_TOKEN")) { + envval <- Sys.getenv(envvar, unset = NA) + if (!is.na(envval)) + return(envval) + } + } + + renv_bootstrap_download_github <- function(version) { + + enabled <- Sys.getenv("RENV_BOOTSTRAP_FROM_GITHUB", unset = "TRUE") + if (!identical(enabled, "TRUE")) + return(FALSE) + + # prepare download options + token <- renv_bootstrap_github_token() + if (nzchar(Sys.which("curl")) && nzchar(token)) { + fmt <- "--location --fail --header \"Authorization: token %s\"" + extra <- sprintf(fmt, token) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "curl", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } else if (nzchar(Sys.which("wget")) && nzchar(token)) { + fmt <- "--header=\"Authorization: token %s\"" + extra <- sprintf(fmt, token) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "wget", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } + + url <- file.path("https://api.github.com/repos/rstudio/renv/tarball", version) + name <- sprintf("renv_%s.tar.gz", version) + destfile <- file.path(tempdir(), name) + + status <- tryCatch( + renv_bootstrap_download_impl(url, destfile), + condition = identity + ) + + if (!identical(status, 0L)) + return(FALSE) + + renv_bootstrap_download_augment(destfile) + + return(destfile) + + } + + # Add Sha to DESCRIPTION. This is stop gap until #890, after which we + # can use renv::install() to fully capture metadata. + renv_bootstrap_download_augment <- function(destfile) { + sha <- renv_bootstrap_git_extract_sha1_tar(destfile) + if (is.null(sha)) { + return() + } + + # Untar + tempdir <- tempfile("renv-github-") + on.exit(unlink(tempdir, recursive = TRUE), add = TRUE) + untar(destfile, exdir = tempdir) + pkgdir <- dir(tempdir, full.names = TRUE)[[1]] + + # Modify description + desc_path <- file.path(pkgdir, "DESCRIPTION") + desc_lines <- readLines(desc_path) + remotes_fields <- c( + "RemoteType: github", + "RemoteHost: api.github.com", + "RemoteRepo: renv", + "RemoteUsername: rstudio", + "RemotePkgRef: rstudio/renv", + paste("RemoteRef: ", sha), + paste("RemoteSha: ", sha) + ) + writeLines(c(desc_lines[desc_lines != ""], remotes_fields), con = desc_path) + + # Re-tar + local({ + old <- setwd(tempdir) + on.exit(setwd(old), add = TRUE) + + tar(destfile, compression = "gzip") + }) + invisible() + } + + # Extract the commit hash from a git archive. Git archives include the SHA1 + # hash as the comment field of the tarball pax extended header + # (see https://www.kernel.org/pub/software/scm/git/docs/git-archive.html) + # For GitHub archives this should be the first header after the default one + # (512 byte) header. + renv_bootstrap_git_extract_sha1_tar <- function(bundle) { + + # open the bundle for reading + # We use gzcon for everything because (from ?gzcon) + # > Reading from a connection which does not supply a 'gzip' magic + # > header is equivalent to reading from the original connection + conn <- gzcon(file(bundle, open = "rb", raw = TRUE)) + on.exit(close(conn)) + + # The default pax header is 512 bytes long and the first pax extended header + # with the comment should be 51 bytes long + # `52 comment=` (11 chars) + 40 byte SHA1 hash + len <- 0x200 + 0x33 + res <- rawToChar(readBin(conn, "raw", n = len)[0x201:len]) + + if (grepl("^52 comment=", res)) { + sub("52 comment=", "", res) + } else { + NULL + } + } + + renv_bootstrap_install <- function(version, tarball, library) { + + # attempt to install it into project library + dir.create(library, showWarnings = FALSE, recursive = TRUE) + output <- renv_bootstrap_install_impl(library, tarball) + + # check for successful install + status <- attr(output, "status") + if (is.null(status) || identical(status, 0L)) + return(status) + + # an error occurred; report it + header <- "installation of renv failed" + lines <- paste(rep.int("=", nchar(header)), collapse = "") + text <- paste(c(header, lines, output), collapse = "\n") + stop(text) + + } + + renv_bootstrap_install_impl <- function(library, tarball) { + + # invoke using system2 so we can capture and report output + bin <- R.home("bin") + exe <- if (Sys.info()[["sysname"]] == "Windows") "R.exe" else "R" + R <- file.path(bin, exe) + + args <- c( + "--vanilla", "CMD", "INSTALL", "--no-multiarch", + "-l", shQuote(path.expand(library)), + shQuote(path.expand(tarball)) + ) + + system2(R, args, stdout = TRUE, stderr = TRUE) + + } + + renv_bootstrap_platform_prefix <- function() { + + # construct version prefix + version <- paste(R.version$major, R.version$minor, sep = ".") + prefix <- paste("R", numeric_version(version)[1, 1:2], sep = "-") + + # include SVN revision for development versions of R + # (to avoid sharing platform-specific artefacts with released versions of R) + devel <- + identical(R.version[["status"]], "Under development (unstable)") || + identical(R.version[["nickname"]], "Unsuffered Consequences") + + if (devel) + prefix <- paste(prefix, R.version[["svn rev"]], sep = "-r") + + # build list of path components + components <- c(prefix, R.version$platform) + + # include prefix if provided by user + prefix <- renv_bootstrap_platform_prefix_impl() + if (!is.na(prefix) && nzchar(prefix)) + components <- c(prefix, components) + + # build prefix + paste(components, collapse = "/") + + } + + renv_bootstrap_platform_prefix_impl <- function() { + + # if an explicit prefix has been supplied, use it + prefix <- Sys.getenv("RENV_PATHS_PREFIX", unset = NA) + if (!is.na(prefix)) + return(prefix) + + # if the user has requested an automatic prefix, generate it + auto <- Sys.getenv("RENV_PATHS_PREFIX_AUTO", unset = NA) + if (is.na(auto) && getRversion() >= "4.4.0") + auto <- "TRUE" + + if (auto %in% c("TRUE", "True", "true", "1")) + return(renv_bootstrap_platform_prefix_auto()) + + # empty string on failure + "" + + } + + renv_bootstrap_platform_prefix_auto <- function() { + + prefix <- tryCatch(renv_bootstrap_platform_os(), error = identity) + if (inherits(prefix, "error") || prefix %in% "unknown") { + + msg <- paste( + "failed to infer current operating system", + "please file a bug report at https://github.com/rstudio/renv/issues", + sep = "; " + ) + + warning(msg) + + } + + prefix + + } + + renv_bootstrap_platform_os <- function() { + + sysinfo <- Sys.info() + sysname <- sysinfo[["sysname"]] + + # handle Windows + macOS up front + if (sysname == "Windows") + return("windows") + else if (sysname == "Darwin") + return("macos") + + # check for os-release files + for (file in c("/etc/os-release", "/usr/lib/os-release")) + if (file.exists(file)) + return(renv_bootstrap_platform_os_via_os_release(file, sysinfo)) + + # check for redhat-release files + if (file.exists("/etc/redhat-release")) + return(renv_bootstrap_platform_os_via_redhat_release()) + + "unknown" + + } + + renv_bootstrap_platform_os_via_os_release <- function(file, sysinfo) { + + # read /etc/os-release + release <- utils::read.table( + file = file, + sep = "=", + quote = c("\"", "'"), + col.names = c("Key", "Value"), + comment.char = "#", + stringsAsFactors = FALSE + ) + + vars <- as.list(release$Value) + names(vars) <- release$Key + + # get os name + os <- tolower(sysinfo[["sysname"]]) + + # read id + id <- "unknown" + for (field in c("ID", "ID_LIKE")) { + if (field %in% names(vars) && nzchar(vars[[field]])) { + id <- vars[[field]] + break + } + } + + # read version + version <- "unknown" + for (field in c("UBUNTU_CODENAME", "VERSION_CODENAME", "VERSION_ID", "BUILD_ID")) { + if (field %in% names(vars) && nzchar(vars[[field]])) { + version <- vars[[field]] + break + } + } + + # join together + paste(c(os, id, version), collapse = "-") + + } + + renv_bootstrap_platform_os_via_redhat_release <- function() { + + # read /etc/redhat-release + contents <- readLines("/etc/redhat-release", warn = FALSE) + + # infer id + id <- if (grepl("centos", contents, ignore.case = TRUE)) + "centos" + else if (grepl("redhat", contents, ignore.case = TRUE)) + "redhat" + else + "unknown" + + # try to find a version component (very hacky) + version <- "unknown" + + parts <- strsplit(contents, "[[:space:]]")[[1L]] + for (part in parts) { + + nv <- tryCatch(numeric_version(part), error = identity) + if (inherits(nv, "error")) + next + + version <- nv[1, 1] + break + + } + + paste(c("linux", id, version), collapse = "-") + + } + + renv_bootstrap_library_root_name <- function(project) { + + # use project name as-is if requested + asis <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT_ASIS", unset = "FALSE") + if (asis) + return(basename(project)) + + # otherwise, disambiguate based on project's path + id <- substring(renv_bootstrap_hash_text(project), 1L, 8L) + paste(basename(project), id, sep = "-") + + } + + renv_bootstrap_library_root <- function(project) { + + prefix <- renv_bootstrap_profile_prefix() + + path <- Sys.getenv("RENV_PATHS_LIBRARY", unset = NA) + if (!is.na(path)) + return(paste(c(path, prefix), collapse = "/")) + + path <- renv_bootstrap_library_root_impl(project) + if (!is.null(path)) { + name <- renv_bootstrap_library_root_name(project) + return(paste(c(path, prefix, name), collapse = "/")) + } + + renv_bootstrap_paths_renv("library", project = project) + + } + + renv_bootstrap_library_root_impl <- function(project) { + + root <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT", unset = NA) + if (!is.na(root)) + return(root) + + type <- renv_bootstrap_project_type(project) + if (identical(type, "package")) { + userdir <- renv_bootstrap_user_dir() + return(file.path(userdir, "library")) + } + + } + + renv_bootstrap_validate_version <- function(version, description = NULL) { + + # resolve description file + # + # avoid passing lib.loc to `packageDescription()` below, since R will + # use the loaded version of the package by default anyhow. note that + # this function should only be called after 'renv' is loaded + # https://github.com/rstudio/renv/issues/1625 + description <- description %||% packageDescription("renv") + + # check whether requested version 'version' matches loaded version of renv + sha <- attr(version, "sha", exact = TRUE) + valid <- if (!is.null(sha)) + renv_bootstrap_validate_version_dev(sha, description) + else + renv_bootstrap_validate_version_release(version, description) + + if (valid) + return(TRUE) + + # the loaded version of renv doesn't match the requested version; + # give the user instructions on how to proceed + dev <- identical(description[["RemoteType"]], "github") + remote <- if (dev) + paste("rstudio/renv", description[["RemoteSha"]], sep = "@") + else + paste("renv", description[["Version"]], sep = "@") + + # display both loaded version + sha if available + friendly <- renv_bootstrap_version_friendly( + version = description[["Version"]], + sha = if (dev) description[["RemoteSha"]] + ) + + fmt <- heredoc(" + renv %1$s was loaded from project library, but this project is configured to use renv %2$s. + - Use `renv::record(\"%3$s\")` to record renv %1$s in the lockfile. + - Use `renv::restore(packages = \"renv\")` to install renv %2$s into the project library. + ") + catf(fmt, friendly, renv_bootstrap_version_friendly(version), remote) + + FALSE + + } + + renv_bootstrap_validate_version_dev <- function(version, description) { + expected <- description[["RemoteSha"]] + is.character(expected) && startswith(expected, version) + } + + renv_bootstrap_validate_version_release <- function(version, description) { + expected <- description[["Version"]] + is.character(expected) && identical(expected, version) + } + + renv_bootstrap_hash_text <- function(text) { + + hashfile <- tempfile("renv-hash-") + on.exit(unlink(hashfile), add = TRUE) + + writeLines(text, con = hashfile) + tools::md5sum(hashfile) + + } + + renv_bootstrap_load <- function(project, libpath, version) { + + # try to load renv from the project library + if (!requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) + return(FALSE) + + # warn if the version of renv loaded does not match + renv_bootstrap_validate_version(version) + + # execute renv load hooks, if any + hooks <- getHook("renv::autoload") + for (hook in hooks) + if (is.function(hook)) + tryCatch(hook(), error = warnify) + + # load the project + renv::load(project) + + TRUE + + } + + renv_bootstrap_profile_load <- function(project) { + + # if RENV_PROFILE is already set, just use that + profile <- Sys.getenv("RENV_PROFILE", unset = NA) + if (!is.na(profile) && nzchar(profile)) + return(profile) + + # check for a profile file (nothing to do if it doesn't exist) + path <- renv_bootstrap_paths_renv("profile", profile = FALSE, project = project) + if (!file.exists(path)) + return(NULL) + + # read the profile, and set it if it exists + contents <- readLines(path, warn = FALSE) + if (length(contents) == 0L) + return(NULL) + + # set RENV_PROFILE + profile <- contents[[1L]] + if (!profile %in% c("", "default")) + Sys.setenv(RENV_PROFILE = profile) + + profile + + } + + renv_bootstrap_profile_prefix <- function() { + profile <- renv_bootstrap_profile_get() + if (!is.null(profile)) + return(file.path("profiles", profile, "renv")) + } + + renv_bootstrap_profile_get <- function() { + profile <- Sys.getenv("RENV_PROFILE", unset = "") + renv_bootstrap_profile_normalize(profile) + } + + renv_bootstrap_profile_set <- function(profile) { + profile <- renv_bootstrap_profile_normalize(profile) + if (is.null(profile)) + Sys.unsetenv("RENV_PROFILE") + else + Sys.setenv(RENV_PROFILE = profile) + } + + renv_bootstrap_profile_normalize <- function(profile) { + + if (is.null(profile) || profile %in% c("", "default")) + return(NULL) + + profile + + } + + renv_bootstrap_path_absolute <- function(path) { + + substr(path, 1L, 1L) %in% c("~", "/", "\\") || ( + substr(path, 1L, 1L) %in% c(letters, LETTERS) && + substr(path, 2L, 3L) %in% c(":/", ":\\") + ) + + } + + renv_bootstrap_paths_renv <- function(..., profile = TRUE, project = NULL) { + renv <- Sys.getenv("RENV_PATHS_RENV", unset = "renv") + root <- if (renv_bootstrap_path_absolute(renv)) NULL else project + prefix <- if (profile) renv_bootstrap_profile_prefix() + components <- c(root, renv, prefix, ...) + paste(components, collapse = "/") + } + + renv_bootstrap_project_type <- function(path) { + + descpath <- file.path(path, "DESCRIPTION") + if (!file.exists(descpath)) + return("unknown") + + desc <- tryCatch( + read.dcf(descpath, all = TRUE), + error = identity + ) + + if (inherits(desc, "error")) + return("unknown") + + type <- desc$Type + if (!is.null(type)) + return(tolower(type)) + + package <- desc$Package + if (!is.null(package)) + return("package") + + "unknown" + + } + + renv_bootstrap_user_dir <- function() { + dir <- renv_bootstrap_user_dir_impl() + path.expand(chartr("\\", "/", dir)) + } + + renv_bootstrap_user_dir_impl <- function() { + + # use local override if set + override <- getOption("renv.userdir.override") + if (!is.null(override)) + return(override) + + # use R_user_dir if available + tools <- asNamespace("tools") + if (is.function(tools$R_user_dir)) + return(tools$R_user_dir("renv", "cache")) + + # try using our own backfill for older versions of R + envvars <- c("R_USER_CACHE_DIR", "XDG_CACHE_HOME") + for (envvar in envvars) { + root <- Sys.getenv(envvar, unset = NA) + if (!is.na(root)) + return(file.path(root, "R/renv")) + } + + # use platform-specific default fallbacks + if (Sys.info()[["sysname"]] == "Windows") + file.path(Sys.getenv("LOCALAPPDATA"), "R/cache/R/renv") + else if (Sys.info()[["sysname"]] == "Darwin") + "~/Library/Caches/org.R-project.R/R/renv" + else + "~/.cache/R/renv" + + } + + renv_bootstrap_version_friendly <- function(version, shafmt = NULL, sha = NULL) { + sha <- sha %||% attr(version, "sha", exact = TRUE) + parts <- c(version, sprintf(shafmt %||% " [sha: %s]", substring(sha, 1L, 7L))) + paste(parts, collapse = "") + } + + renv_bootstrap_exec <- function(project, libpath, version) { + if (!renv_bootstrap_load(project, libpath, version)) + renv_bootstrap_run(version, libpath) + } + + renv_bootstrap_run <- function(version, libpath) { + + # perform bootstrap + bootstrap(version, libpath) + + # exit early if we're just testing bootstrap + if (!is.na(Sys.getenv("RENV_BOOTSTRAP_INSTALL_ONLY", unset = NA))) + return(TRUE) + + # try again to load + if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { + return(renv::load(project = getwd())) + } + + # failed to download or load renv; warn the user + msg <- c( + "Failed to find an renv installation: the project will not be loaded.", + "Use `renv::activate()` to re-initialize the project." + ) + + warning(paste(msg, collapse = "\n"), call. = FALSE) + + } + + renv_json_read <- function(file = NULL, text = NULL) { + + jlerr <- NULL + + # if jsonlite is loaded, use that instead + if ("jsonlite" %in% loadedNamespaces()) { + + json <- tryCatch(renv_json_read_jsonlite(file, text), error = identity) + if (!inherits(json, "error")) + return(json) + + jlerr <- json + + } + + # otherwise, fall back to the default JSON reader + json <- tryCatch(renv_json_read_default(file, text), error = identity) + if (!inherits(json, "error")) + return(json) + + # report an error + if (!is.null(jlerr)) + stop(jlerr) + else + stop(json) + + } + + renv_json_read_jsonlite <- function(file = NULL, text = NULL) { + text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n") + jsonlite::fromJSON(txt = text, simplifyVector = FALSE) + } + + renv_json_read_default <- function(file = NULL, text = NULL) { + + # find strings in the JSON + text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n") + pattern <- '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]' + locs <- gregexpr(pattern, text, perl = TRUE)[[1]] + + # if any are found, replace them with placeholders + replaced <- text + strings <- character() + replacements <- character() + + if (!identical(c(locs), -1L)) { + + # get the string values + starts <- locs + ends <- locs + attr(locs, "match.length") - 1L + strings <- substring(text, starts, ends) + + # only keep those requiring escaping + strings <- grep("[[\\]{}:]", strings, perl = TRUE, value = TRUE) + + # compute replacements + replacements <- sprintf('"\032%i\032"', seq_along(strings)) + + # replace the strings + mapply(function(string, replacement) { + replaced <<- sub(string, replacement, replaced, fixed = TRUE) + }, strings, replacements) + + } + + # transform the JSON into something the R parser understands + transformed <- replaced + transformed <- gsub("{}", "`names<-`(list(), character())", transformed, fixed = TRUE) + transformed <- gsub("[[{]", "list(", transformed, perl = TRUE) + transformed <- gsub("[]}]", ")", transformed, perl = TRUE) + transformed <- gsub(":", "=", transformed, fixed = TRUE) + text <- paste(transformed, collapse = "\n") + + # parse it + json <- parse(text = text, keep.source = FALSE, srcfile = NULL)[[1L]] + + # construct map between source strings, replaced strings + map <- as.character(parse(text = strings)) + names(map) <- as.character(parse(text = replacements)) + + # convert to list + map <- as.list(map) + + # remap strings in object + remapped <- renv_json_read_remap(json, map) + + # evaluate + eval(remapped, envir = baseenv()) + + } + + renv_json_read_remap <- function(json, map) { + + # fix names + if (!is.null(names(json))) { + lhs <- match(names(json), names(map), nomatch = 0L) + rhs <- match(names(map), names(json), nomatch = 0L) + names(json)[rhs] <- map[lhs] + } + + # fix values + if (is.character(json)) + return(map[[json]] %||% json) + + # handle true, false, null + if (is.name(json)) { + text <- as.character(json) + if (text == "true") + return(TRUE) + else if (text == "false") + return(FALSE) + else if (text == "null") + return(NULL) + } + + # recurse + if (is.recursive(json)) { + for (i in seq_along(json)) { + json[i] <- list(renv_json_read_remap(json[[i]], map)) + } + } + + json + + } + + # load the renv profile, if any + renv_bootstrap_profile_load(project) + + # construct path to library root + root <- renv_bootstrap_library_root(project) + + # construct library prefix for platform + prefix <- renv_bootstrap_platform_prefix() + + # construct full libpath + libpath <- file.path(root, prefix) + + # run bootstrap code + renv_bootstrap_exec(project, libpath, version) + + invisible() + +}) diff --git a/blog/2026/mli-select-edmonton/renv/settings.json b/blog/2026/mli-select-edmonton/renv/settings.json new file mode 100644 index 0000000..ffdbb32 --- /dev/null +++ b/blog/2026/mli-select-edmonton/renv/settings.json @@ -0,0 +1,19 @@ +{ + "bioconductor.version": null, + "external.libraries": [], + "ignored.packages": [], + "package.dependency.fields": [ + "Imports", + "Depends", + "LinkingTo" + ], + "ppm.enabled": null, + "ppm.ignored.urls": [], + "r.version": null, + "snapshot.type": "implicit", + "use.cache": true, + "vcs.ignore.cellar": true, + "vcs.ignore.library": true, + "vcs.ignore.local": true, + "vcs.manage.ignores": true +} diff --git a/blog/2026/property-values/index.qmd b/blog/2026/property-values/index.qmd index bc63446..d60fbb4 100644 --- a/blog/2026/property-values/index.qmd +++ b/blog/2026/property-values/index.qmd @@ -6,20 +6,22 @@ categories: [housing, edmonton, zoning, property value] description: "Using a first-difference event study design, I find no evidence that nearby multi-unit infill construction reduces property appreciation rates in Edmonton's mature neighbourhoods — and some evidence it slightly accelerates them." link-external-newwindow: true link-external-icon: false -draft: true +draft: false +execute: + cache: refresh --- In a [previous post](../../2025/property-value-preliminary/), I showed some descriptive evidence that properties near new multi-unit buildings don't appreciate less than properties farther away. That analysis was intentionally preliminary: comparing group medians year-by-year doesn't account for the fact that infill tends to cluster in specific neighbourhoods, on specific lot types, and near properties that may already be on different value trajectories. This post applies more rigorous methods to the same question. Using a first-difference event study design, I control for all time-invariant differences across properties (location, lot size, age, neighbourhood character) and for city-wide appreciation trends. -The answer is the same as the preliminary post: **there is no evidence that multi-unit infill construction reduces nearby property values**, across every specification. +The answer is the same as the preliminary post: **there is no evidence that multi-unit infill construction reduces nearby property values** — and across every specification, the estimates point in the opposite direction. ## Data All data are from Edmonton's open data portal. -- **Historical property assessment data**, 2015–2024. I filter for residential properties (`mill_class_1 == "RESIDENTIAL"`) with positive assessed values. +- **Historical property assessment data**, 2015–2026. I filter for residential properties (`mill_class_1 == "RESIDENTIAL"`) with positive assessed values. Data for 2015–2024 come from a combined historical RDS; 2025 is a separate GeoJSON; 2026 requires joining a property-information shapefile (geometries) with a separate assessment CSV. - **General building permit data**, 2009–2025. Filtered using the same criteria as the preliminary post: permits adding ≥1 unit, excluding excavation-only permits. - **Mature neighbourhood boundaries** (2024 vintage). @@ -48,12 +50,66 @@ library(here) data_dir <- here("../../../data") historical <- read_rds(here(data_dir, "assessment_historical.rds")) %>% - filter(mill_class_1 == "RESIDENTIAL", assessed_value > 0) + filter(mill_class_1 == "RESIDENTIAL", assessed_value > 0) %>% + mutate( + account_number = as.character(as.integer(account_number)), + assessment_year = as.integer(assessment_year) + ) crs <- lambert_conformal_conic_at(historical) historical <- st_transform(historical, crs) +# 2025: same schema as historical, but GeoJSON stores all values as character +assessment_2025 <- read_sf(here(data_dir, "assessment_2025.geojson")) %>% + mutate( + account_number = as.character(as.integer(as.numeric(account_number))), + assessment_year = as.integer(assessment_year), + assessed_value = as.numeric(assessed_value) + ) %>% + filter(mill_class_1 == "RESIDENTIAL", assessed_value > 0) %>% + st_transform(crs) + +# 2026: join geometry shapefile with assessment CSV +property_info_2026 <- read_sf( + here(data_dir, "Property Information (Current Calendar Year)_20260113") +) %>% + st_transform(crs) %>% + rename(account_number = account_nu, neighbourhood_name = neighbou_2) + +assessment_values_2026 <- read_csv( + here( + data_dir, + "Property_Assessment_Data_(Current_Calendar_Year)_20260308.csv" + ), + show_col_types = FALSE +) %>% + rename( + account_number = `Account Number`, + assessed_value = `Assessed Value`, + mill_class_1 = `Assessment Class 1` + ) %>% + mutate(account_number = as.character(as.integer(account_number))) %>% + filter(mill_class_1 == "RESIDENTIAL", assessed_value > 0) + +assessment_2026 <- property_info_2026 %>% + inner_join( + assessment_values_2026 %>% select(account_number, assessed_value, mill_class_1), + by = "account_number" + ) %>% + mutate(assessment_year = 2026L) + +# Combine all years — keep only columns needed for the analysis +analysis_cols <- c( + "account_number", "assessment_year", "assessed_value", + "mill_class_1", "neighbourhood_name" +) +historical <- bind_rows( + historical %>% select(all_of(analysis_cols)), + assessment_2025 %>% select(all_of(analysis_cols)), + assessment_2026 %>% select(all_of(analysis_cols)) +) + mature_neighbourhood <- read_sf(here( data_dir, "Mature Neighbourhoods_20241222.geojson" @@ -168,7 +224,7 @@ bp_multi_2plus <- bp %>% #| label: mature-and-redeveloped # Properties in mature neighbourhoods (identified from 2024 geometries) mature_accounts <- historical %>% - filter(assessment_year == 2024) %>% + filter(assessment_year == 2026) %>% st_filter(st_union(mature_neighbourhood)) %>% pull(account_number) @@ -177,14 +233,14 @@ mature_accounts <- historical %>% # Use all permits from 2014 onward (study period + 1 year prior) bp_multi_study <- bp_multi %>% filter(year >= 2014) -assessment_2024 <- historical %>% filter(assessment_year == 2024) +assessment_2026_sf <- historical %>% filter(assessment_year == 2026) redeveloped_flags <- st_is_within_distance( - assessment_2024, + assessment_2026_sf, bp_multi_study, dist = units::as_units("10 m"), sparse = TRUE ) -redeveloped_accounts <- assessment_2024$account_number[ +redeveloped_accounts <- assessment_2026_sf$account_number[ lengths(redeveloped_flags) > 0 ] ``` @@ -192,10 +248,10 @@ redeveloped_accounts <- assessment_2024$account_number[ ```{r} #| label: merge-data #| cache: true -years_included <- 2015:2024 +years_included <- 2015:2026 merged_fn <- here( "data", - glue::glue("merged_v2_{min(years_included)}_{max(years_included)}.rds") + glue::glue("merged_v3_{min(years_included)}_{max(years_included)}.rds") ) dir.create(here("data"), showWarnings = FALSE) @@ -295,13 +351,12 @@ A first-difference (FD) approach addresses this by using each property's *year-o The event study estimates separate effects at each point in time relative to the first nearby permit: $t = -4, -3, -2, -1$ (pre-treatment), and $t = 0, +1, +2, +3, +4$ (post-treatment). If the parallel trends assumption holds — i.e., treated and control properties in the same neighbourhood would have followed similar appreciation rate trajectories absent treatment — the pre-treatment coefficients should be near zero. Flat pre-trends are not proof of causality, but they are necessary evidence for it. -I use the Sun-Abraham (2021) estimator (`sunab()` in the `fixest` package), which is robust to heterogeneous treatment effects across cohorts — important here because properties receive their first nearby permit in different years throughout 2015–2024. +I use the Sun-Abraham (2021) estimator (`sunab()` in the `fixest` package), which is robust to heterogeneous treatment effects across cohorts — important here because properties receive their first nearby permit in different years throughout 2015–2025. ## Results Each analysis below is shown for two samples side by side. The **all residential** sample covers all residential properties in mature neighbourhoods. The **always-SFH** sample excludes any property that ever had a ≥6-unit permit issued within 10m of itself during the study period — removing properties approaching redevelopment, which tend to be assessed near land value and cluster near new infill. The always-SFH sample most directly answers the policy question: *does building apartments next door hurt my house's value?* - ```{r} #| label: tbl-fd #| tbl-cap: "First-difference estimates: effect of nearby multi-unit permit on year-over-year log assessed value change. All models use neighbourhood × year fixed effects." @@ -355,9 +410,9 @@ modelsummary( ) ``` -The always-SFH estimates (both thresholds) are near zero — consistent with no effect on stable single-family homes. The all-residential estimates are near zero or slightly positive: properties near new multi-unit permits appreciate at similar or slightly faster rates on average, not slower. The ≥2-unit columns show that the null result holds for smaller infill types (including duplex-to-fourplex), not just large apartment buildings. +The always-SFH estimate for the main specification (≥6 units) is +0.005 (95% CI \[0.003, 0.008\]) — stable single-family homes near new apartment buildings appreciate *faster* than comparable properties in the same neighbourhood in the same year, not slower. The all-residential estimate is also positive (+0.012, 95% CI \[0.008, 0.015\]). The ≥2-unit estimates are much larger (+0.034–0.035). These likely reflect property-level selection: duplexes and fourplexes tend to be built on the most desirable streets *within* a neighbourhood, so properties within 50m of ≥2-unit permits are positively selected on within-neighbourhood appreciation. The neighbourhood × year FE removes between-neighbourhood sorting but not this finer-grained street-level selection. The ≥6-unit specification is less subject to this concern and remains the primary estimate. -The first-difference event studies (@fig-es-fd-main and @fig-es-fd-sfh) test whether appreciation *rates* were diverging before treatment. The always-SFH pre-trends are flat, and post-treatment estimates are near zero throughout — a clean null result. The all-residential event study tells a more revealing story: there is a large positive spike at $t = 0$, which then reverts to near zero. This spike is almost certainly driven by properties approaching redevelopment in the all-residential sample: when a multi-unit permit appears nearby, properties that are themselves candidates for demolition see an immediate reassessment of their land value. The always-SFH exclusion removes this effect entirely, confirming that stable single-family homes see no change in appreciation rates. See @tbl-es-fd-sample for treated-observation counts by cohort and relative year. +The first-difference event studies (@fig-es-fd-main and @fig-es-fd-sfh) test whether appreciation *rates* were diverging before treatment. The always-SFH pre-trends are flat and post-treatment estimates are near zero or mildly positive throughout — consistent with no negative effect on stable single-family homes. The all-residential event study tells a more revealing story: there is a large positive spike at $t = 0$, which then reverts toward the aggregate estimate. This spike is almost certainly driven by properties approaching redevelopment in the all-residential sample: when a multi-unit permit appears nearby, properties that are themselves candidates for demolition see an immediate reassessment of their land value. See @tbl-es-fd-sample for treated-observation counts by cohort and relative year. ```{r} #| label: fig-es-fd-main @@ -442,7 +497,7 @@ bind_rows( tab_options(table.font.size = "small") ``` -@fig-es-fd-sfh-robust repeats the always-SFH FD event study restricted to cohorts 2017 and later — the earliest cohort with at least one pre-treatment FD observation. The result is unchanged: all estimates are statistically indistinguishable from zero throughout the full time horizon, with no systematic trend. +@fig-es-fd-sfh-robust repeats the always-SFH FD event study restricted to cohorts 2017 and later — the earliest cohort with at least one pre-treatment FD observation. The result is unchanged: pre-treatment estimates are flat, and there is no post-treatment decline at any point in the time horizon. ```{r} #| label: fig-es-fd-sfh-robust @@ -464,21 +519,20 @@ iplot( abline(v = -0.5, lty = 2, col = "grey50") ``` - ## Caveats **Parallel trends is untestable.** Flat pre-trends are necessary but not sufficient evidence for a causal interpretation. A time-varying confounder that begins exactly when the permit is issued — say, a developer specifically targeting blocks that are about to appreciate for unrelated reasons — could still bias the estimates. The flat pre-trends make this story less plausible, but it cannot be ruled out. -**Developer selection likely biases toward finding a positive effect.** If developers preferentially site multi-unit builds near properties that are already appreciating (e.g., near amenities, on major streets), the "treated" group would be expected to appreciate faster even without the infill. This would work against finding a null result, making the null finding more conservative, not less. +**Developer selection likely biases toward finding a positive effect.** If developers preferentially site multi-unit builds near properties that are already appreciating (e.g., near amenities, on major streets), the "treated" group would be expected to appreciate faster even without the infill. The neighbourhood × year FE controls for neighbourhood-level appreciation trends, but within-neighbourhood street-level selection may remain — particularly for the ≥2-unit threshold. The positive estimates for the ≥6-unit specification should therefore be interpreted as an upper bound on the causal effect: the true effect is at most mildly positive, and could be zero. **Assessed value is not sale price.** Edmonton's assessment data reflects the city's model of market value, not observed transactions. City-wide assessment methodology changes are absorbed by year fixed effects, but property-specific reassessment events are not. Using only properties where the same assessment method applies across all years would be a useful robustness check. -**One-year lag.** The treatment indicator uses permits issued in year $t-1$ to predict assessments in year $t$. Construction timelines vary — larger buildings may take two or more years to complete — so some effects may be lagged further than the specification captures. The event study, which shows no effect even 4+ years post-permit, addresses this concern. +**One-year lag.** The treatment indicator uses permits issued in year $t-1$ to predict assessments in year $t$. Construction timelines vary — larger buildings may take two or more years to complete — so some effects may be lagged further than the specification captures. The event study, which shows no negative effect even 4+ years post-permit, addresses this concern. **The always-SFH sample conditions on an endogenous outcome.** The always-SFH sample excludes properties that were themselves eventually redeveloped within 10m of a ≥6-unit permit during the study period. If infill pressure causes adjacent properties to sell and redevelop, this exclusion removes potentially affected properties. The always-SFH estimates should therefore be interpreted as the effect on properties that did not experience redevelopment pressure — a well-defined and policy-relevant group, but not a random subset. The bias direction is ambiguous but likely toward a more null result. ## Conclusion -Using year-over-year appreciation as the outcome, there is no evidence that multi-unit infill construction reduces neighbouring property values in Edmonton's mature neighbourhoods. The always-SFH estimates are near zero across both the ≥6-unit and ≥2-unit thresholds, and the all-residential estimates are near zero or slightly positive. The event study shows flat pre-trends and no post-treatment decline for stable single-family homes across the full post-treatment window. The all-residential sample does show a large positive spike at $t = 0$, almost certainly driven by properties approaching redevelopment being reassessed at land value — but this is a selection artefact that disappears in the always-SFH sample, confirming that stable homes are unaffected. All specifications use neighbourhood × year fixed effects, comparing treated and control properties within the same neighbourhood and year. +Using year-over-year appreciation as the outcome, there is no evidence that multi-unit infill construction reduces neighbouring property values in Edmonton's mature neighbourhoods — and consistent evidence that it does not. The always-SFH estimate for the primary specification (≥6 units, neighbourhood × year FE) is +0.005 \[0.003, 0.008\]: stable single-family homes near new apartment buildings appreciate slightly *faster* than comparable properties in the same neighbourhood in the same year. The all-residential estimate is also positive (+0.012). The event study shows flat pre-trends and no post-treatment decline for stable single-family homes. The all-residential sample does show a large positive spike at $t = 0$, almost certainly driven by properties approaching redevelopment being reassessed at land value — but this is a selection artefact, not a causal effect on stable homes. This is consistent with the [preliminary post](../../2025/property-value-preliminary/) and with a growing literature finding that upzoning and infill construction do not harm existing homeowners. The fear that new apartments will hurt house prices is not supported by Edmonton's own data. It should not be a reason to restrict where housing can be built. \ No newline at end of file diff --git a/blog/_metadata.yml b/blog/_metadata.yml index f95b359..96a435d 100644 --- a/blog/_metadata.yml +++ b/blog/_metadata.yml @@ -1,7 +1,3 @@ -# Freeze computed outputs -freeze: auto - - # Enable banner style title blocks title-block-banner: true @@ -18,7 +14,6 @@ execute: message: false warning: false cache: true - freeze: true author: - name: Jacob Dawang