Demos
Nine crafts, one connection
Nine questions, from nine kinds of work, each answered with MapleStats tool calls. See for yourself below (heads up: it's amazing).
158
microdata files for the statisticians, keepers of the weights and the margins,
A bachelor's degree, province by province
The share of adults aged 25 to 64 whose highest credential is a bachelor's degree, estimated from the 2021 Census public use microdata file with its survey weights. Each bar is a 95% confidence interval from the file's 16 replicate weights: narrowest in British Columbia and Quebec, widest in Northern Canada and Prince Edward Island, where fewer people are in the sample.
Show the data
| Province | Estimate | 95% CI low | 95% CI high |
|---|---|---|---|
| Newfoundland and Labrador | 13.3% | 12.5% | 14.1% |
| Prince Edward Island | 18.2% | 16.2% | 20.1% |
| Nova Scotia | 20.1% | 19.4% | 20.7% |
| New Brunswick | 16.7% | 16.0% | 17.3% |
| Quebec | 18.1% | 17.9% | 18.4% |
| Ontario | 23.8% | 23.6% | 24.1% |
| Manitoba | 20.3% | 19.8% | 20.9% |
| Saskatchewan | 18.5% | 17.8% | 19.2% |
| Alberta | 21.7% | 21.3% | 22.1% |
| British Columbia | 22.9% | 22.7% | 23.1% |
| Northern Canada | 15.1% | 13.7% | 16.4% |
The calls behind this
call_tool{"name": "statcan_pumf_tabulate", "arguments": {"url": "https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "rows": ["PR", "HDGREE"], "statistic": "share", "filters": {"AGEGRP": ["9", "10", "11", "12", "13", "14", "15", "16"], "HDGREE": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"]}}}R
# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
# (exact: the PUMF ZIP)
# Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================
# 0. Setup ----
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
download.file("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip", mode = "wb")
zip::unzip("data/raw/cen21_ind_98m0001x_part_rec21.zip", exdir = "data/raw/cen21_ind_98m0001x_part_rec21")
file.remove("data/raw/cen21_ind_98m0001x_part_rec21.zip")
data_files <- list.files("data/raw/cen21_ind_98m0001x_part_rec21", full.names = TRUE, recursive = TRUE)Python
# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
# (exact: the PUMF ZIP)
# Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================
# %% 0. Setup
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive:
archive.extractall(RAW_DIR / raw_path.stem)Stata
* ============================================================
* StatCan public use microdata file
* Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
* (exact: the PUMF ZIP)
* Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
* Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/statcan_pumf_tabulate.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive: archive.extractall(RAW_DIR / raw_path.stem)
end
log closeJulia
# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
# (exact: the PUMF ZIP)
# Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================
# 0. Setup
using Downloads
mkpath("data/raw")
# 1. Read inputs
Downloads.download("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip")67
immigration tables for the demographers, counting who comes to stay,
Who comes to stay: Edmonton and Calgary
New permanent residents who named each city as their destination, by year, from IRCC's Monthly Updates. *2026 covers January to July only. IRCC rounds every count to a multiple of 5.
Show the data
| Year | Edmonton | Calgary |
|---|---|---|
| 2015 | 16,745 | 21,715 |
| 2016 | 17,895 | 21,430 |
| 2017 | 15,960 | 17,875 |
| 2018 | 15,750 | 18,950 |
| 2019 | 16,425 | 19,635 |
| 2020 | 8,380 | 10,665 |
| 2021 | 14,760 | 17,870 |
| 2022 | 17,360 | 24,725 |
| 2023 | 21,665 | 27,430 |
| 2024 | 24,075 | 31,165 |
| 2025 | 18,980 | 23,560 |
| 2026* | 10,685 | 13,400 |
The calls behind this
call_tool{"name": "ircc_monthly_query", "arguments": {"table_id": "ODP-PR-PT_CMA", "filters": {"census_metropolitan_area": "Edmonton"}, "period": "year", "year_from": 2015}}call_tool{"name": "ircc_monthly_query", "arguments": {"table_id": "ODP-PR-PT_CMA", "filters": {"census_metropolitan_area": "Calgary"}, "period": "year", "year_from": 2015}}R
# ============================================================
# IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
# Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
# (exact: the source file, then the tool's filters)
# Inputs: https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
# Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
# ============================================================
# 0. Setup ----
library(dplyr)
library(janitor)
library(purrr)
library(readr)
library(stringr)
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
download.file("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv", "data/raw/ODP-PR-PT_CMA.csv", mode = "wb")
data <- read_delim("data/raw/ODP-PR-PT_CMA.csv", delim = "\t", na = c("", "NA", "--"),
col_types = cols(.default = col_character()))
# 2. Check inputs ----
stopifnot("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows" = nrow(data) > 0)
# 3. Prepare data ----
# Keep the rows the MapleStats tool kept.
data <- data |>
filter(
str_to_lower(str_trim(`EN_CENSUS_METROPOLITAN_AREA`)) == "edmonton",
suppressWarnings(as.numeric(`EN_YEAR`)) >= 2015
)
data <- data |>
clean_names()
# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns <- "^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$"
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), "")))
keep_text <- names(data)[
str_detect(names(data), code_columns) |
map_lgl(
data,
\(column) is.character(column) && any(str_detect(column, "^-?0[0-9]"), na.rm = TRUE)
)
]
data <- data |>
mutate(
across(any_of(keep_text), \(x) if (is.list(x)) x else as.character(x)),
across(where(is.character) & !any_of(keep_text), parse_guess)
)Python
# ============================================================
# IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
# Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
# (exact: the source file, then the tool's filters)
# Inputs: https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
# Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
# ============================================================
# %% 0. Setup
import re
import unicodedata
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ODP-PR-PT_CMA.csv"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.get('https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv')
response.raise_for_status()
raw_path.write_bytes(response.content)
data = pl.read_csv(raw_path, separator='\t', infer_schema=False, null_values=['--'])
# %% 2. Check inputs
assert data.height > 0, "https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows"
# %% 3. Prepare data
# Keep the rows the MapleStats tool kept.
data = data.filter(
pl.col('EN_CENSUS_METROPOLITAN_AREA').cast(pl.Utf8).str.strip_chars().str.to_lowercase() == 'edmonton',
pl.col('EN_YEAR').cast(pl.Float64, strict=False) >= 2015,
)
# Standard cleaning: snake_case names as janitor's clean_names() gives them
# in R (PÉRIODE -> periode, referenceNumber -> reference_number, # -> number,
# % -> percent, apostrophes dropped, a leading digit prefixed with x),
# trimmed text, empty strings as missing. Names that clean alike are
# numbered as janitor numbers them (Indicator, indicator -> indicator,
# indicator_2).
clean_names = [
re.sub(
r"[^0-9a-z]+",
"_",
re.sub(
r"([a-z0-9])([A-Z])",
r"\1_\2",
"".join(
" " if unicodedata.category(ch)[0] in "PS" and not ch.isascii() else ch
for ch in unicodedata.normalize("NFKD", column.replace("'", ""))
)
.replace("#", " number ")
.replace("%", " percent ")
.encode("ascii", "ignore")
.decode(),
).lower(),
).strip("_")
or "x"
for column in data.columns
]
clean_names = [f"x{name}" if name[0].isdigit() else name for name in clean_names]
while len(set(clean_names)) < len(clean_names):
name_counts = {}
numbered = []
for name in clean_names:
name_counts[name] = name_counts.get(name, 0) + 1
count = name_counts[name]
numbered.append(name if count == 1 else f"{name}_{count}")
clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))
# Numbers stored as text become numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns = re.compile(r"^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$")
for column in data.columns:
dtype = data.schema[column]
if code_columns.search(column) and not dtype.is_nested():
data = data.with_columns(pl.col(column).cast(pl.Utf8))
continue
if dtype != pl.Utf8 or data[column].null_count() == data.height:
continue
numbers = data[column].cast(pl.Float64, strict=False)
leading_zero = data[column].str.contains(r"^-?0[0-9]").any()
if numbers.null_count() > data[column].null_count() or leading_zero:
continue
whole = data[column].cast(pl.Int64, strict=False)
data = data.with_columns(whole if whole.null_count() == numbers.null_count() else numbers)Stata
* ============================================================
* IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
* Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
* (exact: the source file, then the tool's filters)
* Inputs: https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
* Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/ircc_monthly_query.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ODP-PR-PT_CMA.csv"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv')
response.raise_for_status()
raw_path.write_bytes(response.content)
data = pl.read_csv(raw_path, separator='\t', infer_schema=False, null_values=['--'])
assert data.height > 0, "https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows"
data = data.filter(pl.col('EN_CENSUS_METROPOLITAN_AREA').cast(pl.Utf8).str.strip_chars().str.to_lowercase() == 'edmonton', pl.col('EN_YEAR').cast(pl.Float64, strict=False) >= 2015)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "ODP-PR-PT_CMA_prepared.csv")
end
import delimited "data/raw/ODP-PR-PT_CMA_prepared.csv", clear varnames(1) encoding("utf-8") stringcols(_all) bindquote(strict) maxquotedrows(unlimited)
* 2. Check inputs
* The Python block checked the download for rows before its filters and
* steps, which may leave none (the tool then returns an empty table).
* 3. Prepare data
* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone). Codes
* stay text as the source wrote them: variables named like one (coordinate,
* vectorid, dguid, NAICS, postal code) and ones with a leading zero (01).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.
local names
foreach var of varlist _all {
local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
local numbered
local before
foreach name of local names {
local count 1
foreach earlier of local before {
if "`earlier'" == "`name'" local ++count
}
local before `before' `name'
if `count' > 1 {
local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
}
local numbered `numbered' `name'
}
local names `numbered'
local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
local ++i
local name : word `i' of `names'
if "`name'" != "`var'" {
local old_names `old_names' `var'
local new_names `new_names' `name'
}
}
if "`old_names'" != "" {
rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
if regexm("`var'|", "^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)[|]") continue
quietly count if regexm(`var', "^-?0[0-9]")
if r(N) > 0 continue
destring `var', replace
}
log closeJulia
# ============================================================
# IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
# Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
# (exact: the source file, then the tool's filters)
# Inputs: https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
# Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
# ============================================================
# 0. Setup
using CSV
using DataFrames
using Downloads
using TidierData
mkpath("data/raw")
# 1. Read inputs
Downloads.download("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv", "data/raw/ODP-PR-PT_CMA.csv")
data = CSV.read("data/raw/ODP-PR-PT_CMA.csv", DataFrame, delim = "\t", types = String, missingstring = ["", "--"])
# 2. Check inputs
@assert nrow(data) > 0 "https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows"
# 3. Prepare data
# Keep the rows the MapleStats tool kept.
data = filter(row -> begin
(lowercase(strip(coalesce(string(row["EN_CENSUS_METROPOLITAN_AREA"]), ""))) == "edmonton") &&
(!ismissing(row["EN_YEAR"]) && something(tryparse(Float64, string(row["EN_YEAR"])), NaN) >= 2015)
end, data)
# Standard cleaning: snake_case names, trimmed text, empty strings as missing.
data = @chain data begin
@clean_names
end
data = mapcols(
col -> eltype(col) <: Union{Missing, AbstractString} ?
[ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
data,
)
# Numbers stored as text become numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns = r"^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$"
for name in names(data)
column = data[!, name]
if occursin(code_columns, name)
data[!, name] = [ismissing(x) ? missing : string(x) for x in column]
continue
end
eltype(column) <: Union{Missing, AbstractString} || continue
values = collect(skipmissing(column))
(isempty(values) || any(v -> occursin(r"^-?0[0-9]", v), values)) && continue
any(v -> isnothing(tryparse(Float64, v)), values) && continue
whole = all(v -> !isnothing(tryparse(Int, v)), values)
data[!, name] = [
ismissing(x) ? missing : parse(whole ? Int : Float64, x) for x in column
]
endExcel (Power Query)
// ============================================================
// IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
// Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
// (exact: the source file, then the tool's filters)
// Inputs: https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
// Outputs: the prepared table, loaded to a worksheet or the Data Model
// How to run: Excel > Data > Get Data > From Other Sources > Blank Query >
// Advanced Editor; paste this query, Done, then Close & Load. Answer the
// first prompt with Anonymous access; Data > Refresh All fetches it again.
// A worksheet holds 1,048,576 rows: load a larger table (a full StatCan
// table can be) with Close & Load To > Only Create Connection > Add this
// data to the Data Model.
// ============================================================
let
// 0. Setup
// Cell values as text or number, null-safe, for the filters and checks.
AsText = (value as any) as text =>
if value = null then "" else (try Text.From(value) otherwise ""),
AsNumber = (value as any) as nullable number =>
if value = null then null else (try Number.From(value, "en-US") otherwise null),
// Records to a table with every field any record has, not only the first's.
ToTable = (records as list) as table =>
Table.FromRecords(
records,
List.Distinct(List.Combine(List.Transform(records, Record.FieldNames))),
MissingField.UseNull
),
// 1. Read inputs
Raw = Web.Contents("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv"),
Csv = Csv.Document(Raw, [Delimiter = "#(0009)", Encoding = 65001, QuoteStyle = QuoteStyle.Csv]),
Loaded = Table.PromoteHeaders(Csv, [PromoteAllScalars = true]),
// 2. Check inputs
// Stop with a clear message instead of loading an empty table.
Checked = if Table.IsEmpty(Loaded)
then error Error.Record("MapleStats", "https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows")
else Loaded,
// 3. Prepare data
Headers = Table.TransformColumnNames(
Checked,
(column) =>
let
name = Text.Trim(column, {Character.FromNumber(65279)})
in
name
),
// The source writes missing cells as --.
Missing = Table.TransformColumns(
Headers,
List.Transform(
Table.ColumnNames(Headers),
(name) =>
{name, (value) => if List.Contains({"--"}, value) then null else value}
)
),
// Keep the rows the MapleStats tool kept.
Filtered = Table.SelectRows(
Missing,
each Text.Lower(Text.Trim(AsText(Record.Field(_, "EN_CENSUS_METROPOLITAN_AREA")))) = "edmonton"
and (let n = AsNumber(Record.Field(_, "EN_YEAR")) in n <> null and n >= 2015)
),
// Standard cleaning, as the R script's clean_names() and type_convert():
// snake_case names without accents (PÉRIODE -> periode, referenceNumber ->
// reference_number), names that clean alike numbered (indicator,
// indicator_2), trimmed text, empty text as null, then columns whose
// every value is a number or an ISO date (YYYY-MM-DD) converted.
Accents = [#"à" = "a", #"â" = "a", #"ä" = "a", #"á" = "a", #"ç" = "c", #"é" = "e", #"è" = "e", #"ê" = "e", #"ë" = "e", #"î" = "i", #"ï" = "i", #"í" = "i", #"ô" = "o", #"ö" = "o", #"ó" = "o", #"ù" = "u", #"û" = "u", #"ü" = "u", #"ú" = "u", #"ÿ" = "y", #"ñ" = "n", #"œ" = "oe", #"æ" = "ae", #"À" = "A", #"Â" = "A", #"Ä" = "A", #"Á" = "A", #"Ç" = "C", #"É" = "E", #"È" = "E", #"Ê" = "E", #"Ë" = "E", #"Î" = "I", #"Ï" = "I", #"Í" = "I", #"Ô" = "O", #"Ö" = "O", #"Ó" = "O", #"Ù" = "U", #"Û" = "U", #"Ü" = "U", #"Ú" = "U", #"Ÿ" = "Y", #"Ñ" = "N", #"Œ" = "OE", #"Æ" = "AE"],
CleanName = (name as text) as text =>
let
Plain = Text.Combine(
List.Transform(Text.ToList(name), (ch) => Record.FieldOrDefault(Accents, ch, ch))
),
Words = Splitter.SplitTextByCharacterTransition({"a".."z", "0".."9"}, {"A".."Z"})(
Plain
),
Lower = Text.Lower(Text.Combine(Words, "_")),
Spaced = Text.Combine(
List.Transform(
Text.ToList(Lower), (ch) => if List.Contains(NameCharacters, ch) then ch else " "
)
),
Snake = Text.Combine(List.Select(Text.Split(Spaced, " "), (part) => part <> ""), "_")
in
if Snake = "" then "x" else Snake,
NameCharacters = {"a".."z", "0".."9"},
OldNames = Table.ColumnNames(Filtered),
CleanNames = List.Transform(OldNames, CleanName),
NewNames = List.Transform(
List.Positions(CleanNames),
(i) =>
let
Earlier = List.Count(
List.Select(List.FirstN(CleanNames, i), (name) => name = CleanNames{i})
)
in
if Earlier = 0 then CleanNames{i} else CleanNames{i} & "_" & Text.From(Earlier + 1)
),
Renamed = Table.RenameColumns(
Filtered,
List.Select(List.Zip({OldNames, NewNames}), (pair) => pair{0} <> pair{1})
),
Trimmed = Table.TransformColumns(
Renamed,
List.Transform(
Table.ColumnNames(Renamed),
(name) =>
{
name,
(value) =>
if value is text
then (if Text.Trim(value) = "" then null else Text.Trim(value))
else value
}
)
),
IsNumberText = (value as any) as logical =>
value is number
or (value is text and (try Number.FromText(value, "en-US") otherwise null) <> null),
IsIsoDate = (value as any) as logical =>
value is text
and Text.Length(value) = 10
and Text.Middle(value, 4, 1) = "-"
and Text.Middle(value, 7, 1) = "-"
and (try Date.FromText(value, "en-US") otherwise null) <> null,
ColumnsWhere = (table as table, test as function) as list =>
List.Select(
Table.ColumnNames(table),
(name) =>
let
Values = List.RemoveNulls(Table.Column(table, name))
in
not List.IsEmpty(Values) and List.AllTrue(List.Transform(Values, test))
),
// Codes stay text as the source wrote them: columns named like one
// (coordinate, vector_id, dguid, NAICS, postal code; StatCan's "2.1" and
// "2.10" are different coordinates) and columns with a leading zero (01).
CodeName = (name as text) as logical =>
List.Contains(
{"coordinate", "vector", "vector_id", "vectorid", "product_id", "productid", "pid",
"dguid", "postal_code", "postalcode", "fsa", "noc", "sgc"},
name
)
or Text.EndsWith(name, "_dguid")
or Text.EndsWith(name, "_postal_code")
or Text.Contains(name, "naics")
or Text.StartsWith(name, "noc_")
or Text.EndsWith(name, "_noc")
or Text.StartsWith(name, "sgc_")
or Text.EndsWith(name, "_sgc"),
HasLeadingZero = (value as any) as logical =>
value is text
and (
let
Digits = if Text.StartsWith(value, "-") then Text.Middle(value, 1) else value
in
Text.Length(Digits) > 1
and Text.Start(Digits, 1) = "0"
and List.Contains({"0".."9"}, Text.Middle(Digits, 1, 1))
),
CodeColumns = List.Select(
Table.ColumnNames(Trimmed),
(name) => CodeName(name) or List.AnyTrue(List.Transform(Table.Column(Trimmed, name), HasLeadingZero))
),
Codes = Table.TransformColumns(
Trimmed,
List.Transform(
CodeColumns,
(name) =>
{
name,
(value) =>
if value = null then null
else if value is number then Number.ToText(value, "G", "en-US")
else Text.From(value),
type text
}
)
),
NumberColumns = List.Difference(ColumnsWhere(Codes, IsNumberText), CodeColumns),
Numbers = Table.TransformColumnTypes(
Codes, List.Transform(NumberColumns, (name) => {name, type number}), "en-US"
),
DateColumns = ColumnsWhere(Numbers, IsIsoDate),
Cleaned = Table.TransformColumns(
Numbers,
List.Transform(
DateColumns,
(name) => {name, (value) => if value = null then null else Date.FromText(value, "en-US"), type date}
)
),
Data = Cleaned
in
Data54
housing tables for the urban planners, who count the cranes before the keys,
Canada is building up, not out
Housing starts by year from CMHC, for single-detached homes and apartments, in centres of 10,000 people or more. In 2005, Canada started 93,994 single-detached homes and 66,404 apartments; in 2025, 41,837 and 164,839. Apartments went from 34% of all starts to 68%, and have outnumbered single-detached homes every year since 2011.
Show the data
| Year | Single-detached | Apartments |
|---|---|---|
| 2005 | 93,994 | 66,404 |
| 2006 | 94,110 | 69,150 |
| 2007 | 90,855 | 69,062 |
| 2008 | 74,435 | 83,565 |
| 2009 | 60,521 | 46,388 |
| 2010 | 74,244 | 61,396 |
| 2011 | 67,089 | 77,119 |
| 2012 | 67,172 | 92,951 |
| 2013 | 63,143 | 76,009 |
| 2014 | 62,380 | 76,599 |
| 2015 | 57,739 | 92,564 |
| 2016 | 60,549 | 88,007 |
| 2017 | 63,495 | 100,365 |
| 2018 | 54,180 | 109,820 |
| 2019 | 46,909 | 115,664 |
| 2020 | 49,704 | 119,428 |
| 2021 | 63,456 | 141,684 |
| 2022 | 57,515 | 144,043 |
| 2023 | 42,924 | 147,617 |
| 2024 | 44,357 | 149,113 |
| 2025 | 41,837 | 164,839 |
The calls behind this
call_tool{"name": "cmhc_get_table_data", "arguments": {"category_level_1": "New Housing Construction", "category_level_2": "Starts (Actual)", "column_field": "1", "row_field": "TIMESERIES"}}call_tool{"name": "cmhc_list_categories", "arguments": {}}R
# ============================================================
# Reproduce cmhc_get_table_data
# Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
# (exact: the request the tool made, recorded while it ran)
# Inputs: https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
# Outputs: data/raw/ExportTable.csv; the prepared table as `data`
# ============================================================
# 0. Setup ----
library(dplyr)
library(httr2)
library(janitor)
library(purrr)
library(readr)
library(stringr)
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
request("https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable") |>
req_body_form(!!!list(`TableId` = "5.6.1", `GeographyId` = "1", `GeographyTypeId` = "1", `exportType` = "csv")) |>
req_perform(path = "data/raw/ExportTable.csv")
# The file opens with 2 title line(s) above its header; read the table between.
lines <- read_lines("data/raw/ExportTable.csv", skip = 2)
data <- read_delim(I(str_c(lines, collapse = "\n")), delim = ",",
col_types = cols(.default = col_character()))
# 2. Check inputs ----
stopifnot("https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable returned no rows" = nrow(data) > 0)
# 3. Prepare data ----
data <- data |>
clean_names()
# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns <- "^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$"
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), "")))
keep_text <- names(data)[
str_detect(names(data), code_columns) |
map_lgl(
data,
\(column) is.character(column) && any(str_detect(column, "^-?0[0-9]"), na.rm = TRUE)
)
]
data <- data |>
mutate(
across(any_of(keep_text), \(x) if (is.list(x)) x else as.character(x)),
across(where(is.character) & !any_of(keep_text), parse_guess)
)Python
# ============================================================
# Reproduce cmhc_get_table_data
# Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
# (exact: the request the tool made, recorded while it ran)
# Inputs: https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
# Outputs: data/raw/ExportTable.csv; the prepared table as `data`
# ============================================================
# %% 0. Setup
import io
import re
import unicodedata
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ExportTable.csv"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.post('https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable', data={'TableId': '5.6.1', 'GeographyId': '1', 'GeographyTypeId': '1', 'exportType': 'csv'})
response.raise_for_status()
raw_path.write_bytes(response.content)
# The file opens with 2 title line(s) above its header; read the table between.
lines = raw_path.read_text(encoding="utf-8", errors="replace").splitlines()[2:]
data = pl.read_csv(io.StringIO("\n".join(lines)), separator=',', infer_schema=False)
# %% 2. Check inputs
assert data.height > 0, "https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable returned no rows"
# %% 3. Prepare data
# Standard cleaning: snake_case names as janitor's clean_names() gives them
# in R (PÉRIODE -> periode, referenceNumber -> reference_number, # -> number,
# % -> percent, apostrophes dropped, a leading digit prefixed with x),
# trimmed text, empty strings as missing. Names that clean alike are
# numbered as janitor numbers them (Indicator, indicator -> indicator,
# indicator_2).
clean_names = [
re.sub(
r"[^0-9a-z]+",
"_",
re.sub(
r"([a-z0-9])([A-Z])",
r"\1_\2",
"".join(
" " if unicodedata.category(ch)[0] in "PS" and not ch.isascii() else ch
for ch in unicodedata.normalize("NFKD", column.replace("'", ""))
)
.replace("#", " number ")
.replace("%", " percent ")
.encode("ascii", "ignore")
.decode(),
).lower(),
).strip("_")
or "x"
for column in data.columns
]
clean_names = [f"x{name}" if name[0].isdigit() else name for name in clean_names]
while len(set(clean_names)) < len(clean_names):
name_counts = {}
numbered = []
for name in clean_names:
name_counts[name] = name_counts.get(name, 0) + 1
count = name_counts[name]
numbered.append(name if count == 1 else f"{name}_{count}")
clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))
# Numbers stored as text become numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns = re.compile(r"^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$")
for column in data.columns:
dtype = data.schema[column]
if code_columns.search(column) and not dtype.is_nested():
data = data.with_columns(pl.col(column).cast(pl.Utf8))
continue
if dtype != pl.Utf8 or data[column].null_count() == data.height:
continue
numbers = data[column].cast(pl.Float64, strict=False)
leading_zero = data[column].str.contains(r"^-?0[0-9]").any()
if numbers.null_count() > data[column].null_count() or leading_zero:
continue
whole = data[column].cast(pl.Int64, strict=False)
data = data.with_columns(whole if whole.null_count() == numbers.null_count() else numbers)Stata
* ============================================================
* Reproduce cmhc_get_table_data
* Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
* (exact: the request the tool made, recorded while it ran)
* Inputs: https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
* Outputs: data/raw/ExportTable.csv; the prepared table as `data`
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/cmhc_get_table_data.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import io
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ExportTable.csv"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.post('https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable', data={'TableId': '5.6.1', 'GeographyId': '1', 'GeographyTypeId': '1', 'exportType': 'csv'})
response.raise_for_status()
raw_path.write_bytes(response.content)
lines = raw_path.read_text(encoding="utf-8", errors="replace").splitlines()[2:]
data = pl.read_csv(io.StringIO("\n".join(lines)), separator=',', infer_schema=False)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "ExportTable_prepared.csv")
end
import delimited "data/raw/ExportTable_prepared.csv", clear varnames(1) encoding("utf-8") stringcols(_all) bindquote(strict) maxquotedrows(unlimited)
* 2. Check inputs
assert _N > 0
* 3. Prepare data
* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone). Codes
* stay text as the source wrote them: variables named like one (coordinate,
* vectorid, dguid, NAICS, postal code) and ones with a leading zero (01).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.
local names
foreach var of varlist _all {
local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
local numbered
local before
foreach name of local names {
local count 1
foreach earlier of local before {
if "`earlier'" == "`name'" local ++count
}
local before `before' `name'
if `count' > 1 {
local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
}
local numbered `numbered' `name'
}
local names `numbered'
local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
local ++i
local name : word `i' of `names'
if "`name'" != "`var'" {
local old_names `old_names' `var'
local new_names `new_names' `name'
}
}
if "`old_names'" != "" {
rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
if regexm("`var'|", "^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)[|]") continue
quietly count if regexm(`var', "^-?0[0-9]")
if r(N) > 0 continue
destring `var', replace
}
log closeJulia
# ============================================================
# Reproduce cmhc_get_table_data
# Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
# (exact: the request the tool made, recorded while it ran)
# Inputs: https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
# Outputs: data/raw/ExportTable.csv; the prepared table as `data`
# ============================================================
# 0. Setup
using CSV
using DataFrames
using Downloads
using TidierData
mkpath("data/raw")
# 1. Read inputs
Downloads.request(
"https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable";
headers = ["Content-Type" => "application/x-www-form-urlencoded"], method = "POST", input = IOBuffer("TableId=5.6.1&GeographyId=1&GeographyTypeId=1&exportType=csv"),
output = "data/raw/ExportTable.csv",
)
# The file opens with 2 title line(s) above its header; read the table between.
lines = readlines("data/raw/ExportTable.csv")[(2 + 1):end]
data = CSV.read(IOBuffer(join(lines, "\n")), DataFrame, types = String)
# 2. Check inputs
@assert nrow(data) > 0 "https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable returned no rows"
# 3. Prepare data
# Standard cleaning: snake_case names, trimmed text, empty strings as missing.
data = @chain data begin
@clean_names
end
data = mapcols(
col -> eltype(col) <: Union{Missing, AbstractString} ?
[ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
data,
)
# Numbers stored as text become numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns = r"^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$"
for name in names(data)
column = data[!, name]
if occursin(code_columns, name)
data[!, name] = [ismissing(x) ? missing : string(x) for x in column]
continue
end
eltype(column) <: Union{Missing, AbstractString} || continue
values = collect(skipmissing(column))
(isempty(values) || any(v -> occursin(r"^-?0[0-9]", v), values)) && continue
any(v -> isnothing(tryparse(Float64, v)), values) && continue
whole = all(v -> !isnothing(tryparse(Int, v)), values)
data[!, name] = [
ismissing(x) ? missing : parse(whole ? Int : Float64, x) for x in column
]
endExcel (Power Query)
// ============================================================
// Reproduce cmhc_get_table_data
// Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
// (exact: the request the tool made, recorded while it ran)
// Inputs: https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
// Outputs: the prepared table, loaded to a worksheet or the Data Model
// How to run: Excel > Data > Get Data > From Other Sources > Blank Query >
// Advanced Editor; paste this query, Done, then Close & Load. Answer the
// first prompt with Anonymous access; Data > Refresh All fetches it again.
// A worksheet holds 1,048,576 rows: load a larger table (a full StatCan
// table can be) with Close & Load To > Only Create Connection > Add this
// data to the Data Model.
// ============================================================
let
// 0. Setup
// Cell values as text or number, null-safe, for the filters and checks.
AsText = (value as any) as text =>
if value = null then "" else (try Text.From(value) otherwise ""),
AsNumber = (value as any) as nullable number =>
if value = null then null else (try Number.From(value, "en-US") otherwise null),
// Records to a table with every field any record has, not only the first's.
ToTable = (records as list) as table =>
Table.FromRecords(
records,
List.Distinct(List.Combine(List.Transform(records, Record.FieldNames))),
MissingField.UseNull
),
// 1. Read inputs
Raw = Web.Contents("https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable", [Headers = [#"Content-Type" = "application/x-www-form-urlencoded"], Content = Text.ToBinary(Uri.BuildQueryString([#"TableId" = "5.6.1", #"GeographyId" = "1", #"GeographyTypeId" = "1", #"exportType" = "csv"]))]),
// The file opens with 2 title line(s) above its header; read the table between.
Body = List.Skip(Lines.FromBinary(Raw, null, null, 65001), 2),
Csv = Csv.Document(Text.Combine(Body, "#(cr,lf)"), [Delimiter = ",", Encoding = 65001, QuoteStyle = QuoteStyle.Csv]),
Loaded = Table.PromoteHeaders(Csv, [PromoteAllScalars = true]),
// 2. Check inputs
// Stop with a clear message instead of loading an empty table.
Checked = if Table.IsEmpty(Loaded)
then error Error.Record("MapleStats", "https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable returned no rows")
else Loaded,
// 3. Prepare data
Headers = Table.TransformColumnNames(
Checked,
(column) =>
let
name = Text.Trim(column, {Character.FromNumber(65279)})
in
name
),
// Standard cleaning, as the R script's clean_names() and type_convert():
// snake_case names without accents (PÉRIODE -> periode, referenceNumber ->
// reference_number), names that clean alike numbered (indicator,
// indicator_2), trimmed text, empty text as null, then columns whose
// every value is a number or an ISO date (YYYY-MM-DD) converted.
Accents = [#"à" = "a", #"â" = "a", #"ä" = "a", #"á" = "a", #"ç" = "c", #"é" = "e", #"è" = "e", #"ê" = "e", #"ë" = "e", #"î" = "i", #"ï" = "i", #"í" = "i", #"ô" = "o", #"ö" = "o", #"ó" = "o", #"ù" = "u", #"û" = "u", #"ü" = "u", #"ú" = "u", #"ÿ" = "y", #"ñ" = "n", #"œ" = "oe", #"æ" = "ae", #"À" = "A", #"Â" = "A", #"Ä" = "A", #"Á" = "A", #"Ç" = "C", #"É" = "E", #"È" = "E", #"Ê" = "E", #"Ë" = "E", #"Î" = "I", #"Ï" = "I", #"Í" = "I", #"Ô" = "O", #"Ö" = "O", #"Ó" = "O", #"Ù" = "U", #"Û" = "U", #"Ü" = "U", #"Ú" = "U", #"Ÿ" = "Y", #"Ñ" = "N", #"Œ" = "OE", #"Æ" = "AE"],
CleanName = (name as text) as text =>
let
Plain = Text.Combine(
List.Transform(Text.ToList(name), (ch) => Record.FieldOrDefault(Accents, ch, ch))
),
Words = Splitter.SplitTextByCharacterTransition({"a".."z", "0".."9"}, {"A".."Z"})(
Plain
),
Lower = Text.Lower(Text.Combine(Words, "_")),
Spaced = Text.Combine(
List.Transform(
Text.ToList(Lower), (ch) => if List.Contains(NameCharacters, ch) then ch else " "
)
),
Snake = Text.Combine(List.Select(Text.Split(Spaced, " "), (part) => part <> ""), "_")
in
if Snake = "" then "x" else Snake,
NameCharacters = {"a".."z", "0".."9"},
OldNames = Table.ColumnNames(Headers),
CleanNames = List.Transform(OldNames, CleanName),
NewNames = List.Transform(
List.Positions(CleanNames),
(i) =>
let
Earlier = List.Count(
List.Select(List.FirstN(CleanNames, i), (name) => name = CleanNames{i})
)
in
if Earlier = 0 then CleanNames{i} else CleanNames{i} & "_" & Text.From(Earlier + 1)
),
Renamed = Table.RenameColumns(
Headers,
List.Select(List.Zip({OldNames, NewNames}), (pair) => pair{0} <> pair{1})
),
Trimmed = Table.TransformColumns(
Renamed,
List.Transform(
Table.ColumnNames(Renamed),
(name) =>
{
name,
(value) =>
if value is text
then (if Text.Trim(value) = "" then null else Text.Trim(value))
else value
}
)
),
IsNumberText = (value as any) as logical =>
value is number
or (value is text and (try Number.FromText(value, "en-US") otherwise null) <> null),
IsIsoDate = (value as any) as logical =>
value is text
and Text.Length(value) = 10
and Text.Middle(value, 4, 1) = "-"
and Text.Middle(value, 7, 1) = "-"
and (try Date.FromText(value, "en-US") otherwise null) <> null,
ColumnsWhere = (table as table, test as function) as list =>
List.Select(
Table.ColumnNames(table),
(name) =>
let
Values = List.RemoveNulls(Table.Column(table, name))
in
not List.IsEmpty(Values) and List.AllTrue(List.Transform(Values, test))
),
// Codes stay text as the source wrote them: columns named like one
// (coordinate, vector_id, dguid, NAICS, postal code; StatCan's "2.1" and
// "2.10" are different coordinates) and columns with a leading zero (01).
CodeName = (name as text) as logical =>
List.Contains(
{"coordinate", "vector", "vector_id", "vectorid", "product_id", "productid", "pid",
"dguid", "postal_code", "postalcode", "fsa", "noc", "sgc"},
name
)
or Text.EndsWith(name, "_dguid")
or Text.EndsWith(name, "_postal_code")
or Text.Contains(name, "naics")
or Text.StartsWith(name, "noc_")
or Text.EndsWith(name, "_noc")
or Text.StartsWith(name, "sgc_")
or Text.EndsWith(name, "_sgc"),
HasLeadingZero = (value as any) as logical =>
value is text
and (
let
Digits = if Text.StartsWith(value, "-") then Text.Middle(value, 1) else value
in
Text.Length(Digits) > 1
and Text.Start(Digits, 1) = "0"
and List.Contains({"0".."9"}, Text.Middle(Digits, 1, 1))
),
CodeColumns = List.Select(
Table.ColumnNames(Trimmed),
(name) => CodeName(name) or List.AnyTrue(List.Transform(Table.Column(Trimmed, name), HasLeadingZero))
),
Codes = Table.TransformColumns(
Trimmed,
List.Transform(
CodeColumns,
(name) =>
{
name,
(value) =>
if value = null then null
else if value is number then Number.ToText(value, "G", "en-US")
else Text.From(value),
type text
}
)
),
NumberColumns = List.Difference(ColumnsWhere(Codes, IsNumberText), CodeColumns),
Numbers = Table.TransformColumnTypes(
Codes, List.Transform(NumberColumns, (name) => {name, type number}), "en-US"
),
DateColumns = ColumnsWhere(Numbers, IsIsoDate),
Cleaned = Table.TransformColumns(
Numbers,
List.Transform(
DateColumns,
(name) => {name, (value) => if value = null then null else Date.FromText(value, "en-US"), type date}
)
),
Data = Cleaned
in
Data962,449
census records for the microeconomists, weavers of incentives, one life at a time,
Low income across immigrant generations
The share of people below the low-income measure after tax, by immigrant generation, from the same 2021 Census microdata, with 95% confidence intervals from its replicate weights. First-generation immigrants are the most likely to be in low income (14.0%). Their children, born in Canada, are the least likely (9.1% and 8.8%), below the third generation or more (10.3%).
Show the data
| Generation | Estimate | 95% CI low | 95% CI high |
|---|---|---|---|
| First generation (born abroad) | 14.0% | 13.8% | 14.1% |
| Second generation, both parents born abroad | 9.1% | 9.0% | 9.3% |
| Second generation, one parent born abroad | 8.8% | 8.6% | 9.1% |
| Third generation or more | 10.3% | 10.2% | 10.4% |
The calls behind this
call_tool{"name": "statcan_pumf_tabulate", "arguments": {"url": "https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "rows": ["GENSTAT", "LOLIMA"], "statistic": "share", "filters": {"GENSTAT": ["1", "2", "3", "4"], "LOLIMA": ["1", "2"]}}}R
# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
# (exact: the PUMF ZIP)
# Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================
# 0. Setup ----
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
download.file("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip", mode = "wb")
zip::unzip("data/raw/cen21_ind_98m0001x_part_rec21.zip", exdir = "data/raw/cen21_ind_98m0001x_part_rec21")
file.remove("data/raw/cen21_ind_98m0001x_part_rec21.zip")
data_files <- list.files("data/raw/cen21_ind_98m0001x_part_rec21", full.names = TRUE, recursive = TRUE)Python
# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
# (exact: the PUMF ZIP)
# Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================
# %% 0. Setup
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive:
archive.extractall(RAW_DIR / raw_path.stem)Stata
* ============================================================
* StatCan public use microdata file
* Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
* (exact: the PUMF ZIP)
* Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
* Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/statcan_pumf_tabulate.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive: archive.extractall(RAW_DIR / raw_path.stem)
end
log closeJulia
# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
# (exact: the PUMF ZIP)
# Inputs: https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================
# 0. Setup
using Downloads
mkpath("data/raw")
# 1. Read inputs
Downloads.download("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip")98
credit cards for the marketers, readers of the rivals' fine print,
What a rewards card costs
Every card listed for Alberta in the credit card comparison tool of the Financial Consumer Agency of Canada (FCAC), by its annual fee and its purchase interest rate, in Canadian dollars. Student and secured cards are separate searches there and are not included. The 82 cards with rewards have a median fee of $99 and a median rate of 21.99%; the 15 without rewards, $25 and 13.99%. Larger dots are several cards at the same price. One prepaid card, which charges no interest, is left out.
Show the data
| Type | Annual fee | Purchase rate | Cards |
|---|---|---|---|
| With rewards | $0 | 19.99% | 1 |
| With rewards | $0 | 20.90% | 3 |
| With rewards | $0 | 20.95% | 2 |
| With rewards | $0 | 20.99% | 7 |
| With rewards | $0 | 21.74% | 1 |
| With rewards | $0 | 21.75% | 1 |
| With rewards | $0 | 21.90% | 3 |
| With rewards | $0 | 21.99% | 15 |
| With rewards | $39 | 20.99% | 1 |
| With rewards | $39 | 21.99% | 1 |
| With rewards | $50 | 12.99% | 1 |
| With rewards | $75 | 20.99% | 1 |
| With rewards | $89 | 21.99% | 3 |
| With rewards | $99 | 20.99% | 1 |
| With rewards | $99 | 21.99% | 1 |
| With rewards | $100 | 20.90% | 1 |
| With rewards | $110 | 20.90% | 1 |
| With rewards | $119.88 | 21.99% | 1 |
| With rewards | $120 | 20.95% | 1 |
| With rewards | $120 | 20.99% | 4 |
| With rewards | $120 | 21.99% | 3 |
| With rewards | $120 | 30.00% | 1 |
| With rewards | $130 | 20.90% | 1 |
| With rewards | $130 | 20.99% | 1 |
| With rewards | $139 | 20.99% | 1 |
| With rewards | $139 | 21.99% | 7 |
| With rewards | $150 | 20.99% | 1 |
| With rewards | $150 | 21.99% | 3 |
| With rewards | $165 | 20.50% | 1 |
| With rewards | $191.88 | 21.99% | 1 |
| With rewards | $199 | 21.99% | 1 |
| With rewards | $250 | 21.99% | 1 |
| With rewards | $250 | 30.00% | 1 |
| With rewards | $395 | 11.90% | 1 |
| With rewards | $399 | 20.99% | 1 |
| With rewards | $499 | 21.99% | 1 |
| With rewards | $599 | 21.99% | 4 |
| With rewards | $599 | 30.00% | 1 |
| With rewards | $799 | 30.00% | 1 |
| No rewards | $0 | 10.90% | 1 |
| No rewards | $0 | 12.99% | 1 |
| No rewards | $0 | 21.99% | 1 |
| No rewards | $0 | 29.90% | 2 |
| No rewards | $20 | 12.99% | 1 |
| No rewards | $20 | 13.99% | 1 |
| No rewards | $25 | 12.90% | 1 |
| No rewards | $25 | 12.99% | 1 |
| No rewards | $25 | 13.99% | 1 |
| No rewards | $29 | 13.99% | 3 |
| No rewards | $30 | 12.99% | 1 |
| No rewards | $39 | 10.99% | 1 |
The calls behind this
call_tool{"name": "fcac_search_credit_cards", "arguments": {"province": "AB", "limit": 100}}reproduce_code: This is a web page the tool parses, not a data table, so a download does not reproduce the result. Cite the URL and retrieval date, or keep the tool's output as the raw input.6,350,070
RRSP contributors for the wealth managers, who advise on the years still ahead,
Who funds the RRSP
For the 2023 tax year, 6,350,070 tax filers put $55.7 billion into a registered retirement savings plan, with a median contribution of $4,000. This chart splits those dollars by the age of the contributor. 65% of the money comes from people aged 45 and over, and the largest share, 29%, from those aged 55 to 64. The figures come from Statistics Canada's table 11-10-0044, built from Canada Revenue Agency tax records, and Statistics Canada publishes the age shares rounded to whole percentages. The same table breaks them down by province and city.
Show the data
| Age group | Share of contributions |
|---|---|
| Under 25 | 1% |
| 25 to 34 | 13% |
| 35 to 44 | 21% |
| 45 to 54 | 25% |
| 55 to 64 | 29% |
| 65 and over | 11% |
The calls behind this
call_tool{"name": "wds_get_data_from_cube_coord", "arguments": {"product_id": 11100044, "coordinate": "1.4", "latest_n": 3}}8 more calls like this one, with other arguments.
R
# ============================================================
# StatCan table 11-10-0044-01
# Purpose: Fetch the data behind MapleStats MCP's wds_get_data_from_cube_coord
# (exact: full table from the product id)
# Inputs: https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip
# Outputs: data/raw/11100044-eng.zip; the prepared table as `data`
# ============================================================
# 0. Setup ----
library(cansim)
library(dplyr)
library(janitor)
library(readr)
library(stringr)
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
data <- get_cansim("11-10-0044-01")
# 2. Check inputs ----
stopifnot("https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip returned no rows" = nrow(data) > 0)
# 3. Prepare data ----
data <- data |>
clean_names()
# cansim adds val_norm (VALUE times its scalar factor) and a Date column;
# keep STATUS and SYMBOL, which flag estimates to use with caution.
data <- data |>
filter(!is.na(val_norm))
# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
type_convert()Python
# ============================================================
# StatCan table 11-10-0044-01
# Purpose: Fetch the data behind MapleStats MCP's wds_get_data_from_cube_coord
# (exact: full table from the product id)
# Inputs: https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip
# Outputs: data/raw/11100044-eng.zip; the prepared table as `data`
# ============================================================
# %% 0. Setup
import re
import unicodedata
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "11100044-eng.zip"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.get('https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
# The ZIP may also hold a metadata file; read the data file.
with zipfile.ZipFile(raw_path) as archive:
data_name = next(n for n in archive.namelist() if n.lower().endswith((".csv", ".txt")) and "metadata" not in n.lower())
archive.extract(data_name, RAW_DIR)
data = pl.read_csv(RAW_DIR / data_name, separator=',', infer_schema_length=100_000)
# %% 2. Check inputs
assert data.height > 0, "https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip returned no rows"
# %% 3. Prepare data
# VALUE is in the unit named by SCALAR_FACTOR; SCALAR_ID is its power of ten.
data = data.with_columns(
(pl.col("VALUE") * 10 ** pl.col("SCALAR_ID").cast(pl.Int64)).alias(
"VALUE_NORMALIZED"
)
)
# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing. Names that clean alike are numbered as janitor
# numbers them (Indicator, indicator -> indicator, indicator_2).
clean_names = [
re.sub(
r"[^0-9a-z]+",
"_",
re.sub(
r"([a-z0-9])([A-Z])",
r"\1_\2",
unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
).lower(),
).strip("_")
for column in data.columns
]
while len(set(clean_names)) < len(clean_names):
name_counts = {}
numbered = []
for name in clean_names:
name_counts[name] = name_counts.get(name, 0) + 1
count = name_counts[name]
numbered.append(name if count == 1 else f"{name}_{count}")
clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))Stata
* ============================================================
* StatCan table 11-10-0044-01
* Purpose: Fetch the data behind MapleStats MCP's wds_get_data_from_cube_coord
* (exact: full table from the product id)
* Inputs: https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip
* Outputs: data/raw/11100044-eng.zip; the prepared table as `data`
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/wds_get_data_from_cube_coord.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "11100044-eng.zip"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive: data_name = next(n for n in archive.namelist() if n.lower().endswith((".csv", ".txt")) and "metadata" not in n.lower()); archive.extract(data_name, RAW_DIR)
data = pl.read_csv(RAW_DIR / data_name, separator=',', infer_schema_length=100_000)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "11100044-eng_prepared.csv")
end
import delimited "data/raw/11100044-eng_prepared.csv", clear varnames(1) encoding("utf-8")
* 2. Check inputs
assert _N > 0
* 3. Prepare data
* value is in the unit named by scalar_factor; scalar_id is its power of ten.
generate double value_normalized = value * 10^scalar_id
* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.
local names
foreach var of varlist _all {
local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
local numbered
local before
foreach name of local names {
local count 1
foreach earlier of local before {
if "`earlier'" == "`name'" local ++count
}
local before `before' `name'
if `count' > 1 {
local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
}
local numbered `numbered' `name'
}
local names `numbered'
local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
local ++i
local name : word `i' of `names'
if "`name'" != "`var'" {
local old_names `old_names' `var'
local new_names `new_names' `name'
}
}
if "`old_names'" != "" {
rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
}
destring, replace
log closeJulia
# ============================================================
# StatCan table 11-10-0044-01
# Purpose: Fetch the data behind MapleStats MCP's wds_get_data_from_cube_coord
# (exact: full table from the product id)
# Inputs: https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip
# Outputs: data/raw/11100044-eng.zip; the prepared table as `data`
# ============================================================
# 0. Setup
using Downloads
using TidierData
using TidierFiles
using ZipFile
mkpath("data/raw")
# 1. Read inputs
Downloads.download("https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip", "data/raw/11100044-eng.zip")
archive = ZipFile.Reader("data/raw/11100044-eng.zip")
# The ZIP may also hold a metadata file; read the data file.
data_file = first(f for f in archive.files if (endswith(lowercase(f.name), ".csv") || endswith(lowercase(f.name), ".txt")) &&
!occursin("metadata", lowercase(f.name)))
write("data/raw/table.csv", read(data_file))
close(archive)
data = read_csv("data/raw/table.csv")
# 2. Check inputs
@assert nrow(data) > 0 "https://www150.statcan.gc.ca/n1/tbl/csv/11100044-eng.zip returned no rows"
# 3. Prepare data
# VALUE is in the unit named by SCALAR_FACTOR; SCALAR_ID is its power of ten.
data.VALUE_NORMALIZED = data.VALUE .* 10.0 .^ data.SCALAR_ID
# Standard cleaning: snake_case names, trimmed text, empty strings as missing.
data = @chain data begin
@clean_names
end
data = mapcols(
col -> eltype(col) <: Union{Missing, AbstractString} ?
[ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
data,
)6,434
days of bond yields for the macroeconomists, reading the curve before the turn,
What bond markets expect, in one line
The gap between the 10-year and the 2-year Government of Canada benchmark bond yields, every business day since 2001, from the Bank of Canada's Valet API, drawn as a monthly average. When the 2-year pays more than the 10-year, the curve is inverted: markets expect rates to fall, usually because they expect the economy to slow. It inverted in 2007, 2019 to 2020 and 2022 to 2024. The deepest month was −123 bp, in July 2023; in September 2026 it averaged 62 bp.
Show the data
| Year | Average | Lowest month | Highest month |
|---|---|---|---|
| 2001 | 117 bp | 41 bp | 213 bp |
| 2002 | 169 bp | 139 bp | 225 bp |
| 2003 | 155 bp | 124 bp | 188 bp |
| 2004 | 165 bp | 126 bp | 199 bp |
| 2005 | 88 bp | 23 bp | 131 bp |
| 2006 | 18 bp | 6 bp | 32 bp |
| 2007 | 9 bp | −5 bp | 32 bp |
| 2008 | 95 bp | 54 bp | 171 bp |
| 2009 | 200 bp | 167 bp | 218 bp |
| 2010 | 169 bp | 139 bp | 221 bp |
| 2011 | 141 bp | 110 bp | 158 bp |
| 2012 | 76 bp | 65 bp | 100 bp |
| 2013 | 115 bp | 76 bp | 157 bp |
| 2014 | 118 bp | 84 bp | 150 bp |
| 2015 | 97 bp | 77 bp | 117 bp |
| 2016 | 69 bp | 49 bp | 96 bp |
| 2017 | 69 bp | 34 bp | 95 bp |
| 2018 | 29 bp | 8 bp | 51 bp |
| 2019 | 0 bp | −18 bp | 14 bp |
| 2020 | 24 bp | −12 bp | 49 bp |
| 2021 | 88 bp | 47 bp | 124 bp |
| 2022 | −13 bp | −87 bp | 58 bp |
| 2023 | −91 bp | −123 bp | −71 bp |
| 2024 | −33 bp | −69 bp | 17 bp |
| 2025 | 63 bp | 36 bp | 81 bp |
| 2026 | 70 bp | 61 bp | 84 bp |
The calls behind this
call_tool{"name": "boc_get_observations", "arguments": {"series_names": ["BD.CDN.2YR.DQ.YLD", "BD.CDN.10YR.DQ.YLD"], "start_date": "2001-01-01"}}R
# ============================================================
# Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
# (exact: Valet request rebuilt from the tool's arguments)
# Inputs: https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================
# 0. Setup ----
library(dplyr)
library(httr2)
library(janitor)
library(jsonlite)
library(lubridate)
library(readr)
library(stringr)
library(tibble)
library(tidyr)
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
response <- request("https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01") |>
req_perform()
writeLines(resp_body_string(response), "data/raw/valet_observations.json")
payload <- fromJSON("data/raw/valet_observations.json", flatten = TRUE)
data <- as_tibble(payload[["observations"]])
# 2. Check inputs ----
stopifnot("https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01 returned no rows" = nrow(data) > 0)
# 3. Prepare data ----
data <- data |>
clean_names()
# Valet nests each series as <series>.v; make one row per date and series.
data <- data |>
pivot_longer(-d, names_to = "series", values_to = "value") |>
mutate(
series = str_remove(series, "_v$") |> str_to_upper(),
value = as.numeric(value),
date = ymd(d)
) |>
select(date, series, value)
# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
type_convert()Python
# ============================================================
# Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
# (exact: Valet request rebuilt from the tool's arguments)
# Inputs: https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================
# %% 0. Setup
import json
import re
import unicodedata
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.get('https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)
payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)
# %% 2. Check inputs
assert data.height > 0, "https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01 returned no rows"
# %% 3. Prepare data
# Valet nests each series as <series>.v; make one row per date and series.
data = data.unpivot(index="d", variable_name="series", value_name="value")
data = data.with_columns(
pl.col("d").str.to_date().alias("date"),
pl.col("series").str.replace(r"\.v$", ""),
pl.col("value").cast(pl.Float64, strict=False),
).select("date", "series", "value")
# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing. Names that clean alike are numbered as janitor
# numbers them (Indicator, indicator -> indicator, indicator_2).
clean_names = [
re.sub(
r"[^0-9a-z]+",
"_",
re.sub(
r"([a-z0-9])([A-Z])",
r"\1_\2",
unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
).lower(),
).strip("_")
for column in data.columns
]
while len(set(clean_names)) < len(clean_names):
name_counts = {}
numbered = []
for name in clean_names:
name_counts[name] = name_counts.get(name, 0) + 1
count = name_counts[name]
numbered.append(name if count == 1 else f"{name}_{count}")
clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))Stata
* ============================================================
* Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
* Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
* (exact: Valet request rebuilt from the tool's arguments)
* Inputs: https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
* Outputs: data/raw/valet_observations.json; the prepared table as `data`
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/boc_get_observations.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)
payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "valet_observations_prepared.csv")
end
import delimited "data/raw/valet_observations_prepared.csv", clear varnames(1) encoding("utf-8")
* 2. Check inputs
assert _N > 0
* 3. Prepare data
* One column per series (<series>_v), one row per date.
generate date = date(d, "YMD")
format date %td
drop d
* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.
local names
foreach var of varlist _all {
local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
local numbered
local before
foreach name of local names {
local count 1
foreach earlier of local before {
if "`earlier'" == "`name'" local ++count
}
local before `before' `name'
if `count' > 1 {
local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
}
local numbered `numbered' `name'
}
local names `numbered'
local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
local ++i
local name : word `i' of `names'
if "`name'" != "`var'" {
local old_names `old_names' `var'
local new_names `new_names' `name'
}
}
if "`old_names'" != "" {
rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
}
destring, replace
log closeJulia
# ============================================================
# Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
# (exact: Valet request rebuilt from the tool's arguments)
# Inputs: https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================
# 0. Setup
using DataFrames
using Downloads
using JSON3
using Tables
using TidierData
mkpath("data/raw")
# 1. Read inputs
Downloads.download("https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01", "data/raw/valet_observations.json")
payload = JSON3.read(read("data/raw/valet_observations.json", String))
records = payload["observations"]
data = DataFrame(Tables.dictrowtable(records))
# 2. Check inputs
@assert nrow(data) > 0 "https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01 returned no rows"
# 3. Prepare data
# Standard cleaning: snake_case names, trimmed text, empty strings as missing.
data = @chain data begin
@clean_names
end
data = mapcols(
col -> eltype(col) <: Union{Missing, AbstractString} ?
[ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
data,
)1,610,458
patents for the scientists, checking who got there first,
Machine learning at the patent office
Canadian patent applications in IPC class G06N, where machine learning is classified, by filing year, from ISED's IP Horizons data. Applications went from 24 in 2005 to a peak of 667 in 2021. The counts come from the IP Horizons bulk release dated 11 October 2024, the newest when I recorded the calls. Applications are published about 18 months after filing, so that release holds only part of those filed from 2023 to 2025 (122 so far), and those years are left out.
Show the data
| Filing year | G06N |
|---|---|
| 2005 | 24 |
| 2006 | 15 |
| 2007 | 24 |
| 2008 | 39 |
| 2009 | 18 |
| 2010 | 24 |
| 2011 | 12 |
| 2012 | 26 |
| 2013 | 36 |
| 2014 | 46 |
| 2015 | 56 |
| 2016 | 77 |
| 2017 | 178 |
| 2018 | 256 |
| 2019 | 478 |
| 2020 | 529 |
| 2021 | 667 |
| 2022 | 654 |
The calls behind this
call_tool{"name": "ised_ip_horizons_search_patents", "arguments": {"filed_from": "1800-01-01", "limit": 1}}21 more calls like this one, with other arguments.
R
# ============================================================
# Canadian patent search (CIPO IP Horizons)
# Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
# (exact: the IP Horizons tables the search used, then the tool's filters)
# Inputs: https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
# Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
# ============================================================
# 0. Setup ----
library(dplyr)
library(janitor)
library(purrr)
library(readr)
library(stringr)
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
files <- c(
main_1 = "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip",
main_2 = "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip"
)
walk2(
files,
names(files),
\(url, name) download.file(url, str_c("data/raw/", name, ".zip"), mode = "wb")
)
# Every column as text; headers are bilingual ("Patent Number - Numéro du
# brevet"), so keep the English half in snake_case.
tables <- names(files) |>
map(
\(name) unzip(str_c("data/raw/", name, ".zip"), exdir = "data/raw") |>
read_delim(
delim = "|",
quote = "",
na = c("", "NULL"),
col_types = cols(.default = col_character()),
trim_ws = TRUE
) |>
rename_with(\(x) str_split_i(x, " - ", 1) |> make_clean_names())
) |>
set_names(names(files))
patents <- bind_rows(tables[c("main_1", "main_2")]) |>
filter(str_detect(filing_date, "^\\d{4}-\\d{2}-\\d{2}$"), filing_date >= "1800-01-01")
data <- patents |>
arrange(desc(filing_date))
# 2. Check inputs ----
stopifnot("https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip returned no rows" = nrow(data) > 0)
# 3. Prepare data ----
data <- data |>
clean_names()
# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
type_convert()Python
# ============================================================
# Canadian patent search (CIPO IP Horizons)
# Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
# (exact: the IP Horizons tables the search used, then the tool's filters)
# Inputs: https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
# Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
# ============================================================
# %% 0. Setup
import re
import ssl
import unicodedata
import zipfile
from pathlib import Path
import certifi
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "PT_main_1_to_2000000_2024-10-11.zip"
# %% 1. Read inputs
# CIPO's server omits its RapidSSL intermediate certificate; add it from the
# issuer on top of certifi's roots, so verification still anchors at a root.
intermediate = httpx.get("http://cacerts.rapidssl.com/RapidSSLTLSRSACAG1.crt").content
tls = ssl.create_default_context(cafile=certifi.where())
tls.load_verify_locations(cadata=ssl.DER_cert_to_PEM_cert(intermediate))
files = {
'main_1': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip',
'main_2': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip',
}
tables = {}
with httpx.Client(verify=tls, timeout=900, follow_redirects=True) as client:
for name, url in files.items():
zip_path = RAW_DIR / f"{name}.zip"
with client.stream("GET", url) as response, zip_path.open("wb") as handle:
response.raise_for_status()
for chunk in response.iter_bytes(1 << 20):
handle.write(chunk)
with zipfile.ZipFile(zip_path) as archive:
member = archive.namelist()[0]
archive.extract(member, RAW_DIR)
# Every column as text; headers are bilingual ("Patent Number - Numéro
# du brevet"), so keep the English half in snake_case.
tables[name] = (
pl.scan_csv(
RAW_DIR / member,
separator="|",
quote_char=None,
null_values=["NULL"],
infer_schema=False,
)
.rename(
lambda column: re.sub(
r"[^a-z0-9]+", "_", column.split(" - ")[0].strip().lower()
).strip("_")
)
.with_columns(pl.all().str.strip_chars())
)
patents = pl.concat([tables[name] for name in ['main_1', 'main_2']]).filter(pl.col("filing_date").str.contains(r"^\d{4}-\d{2}-\d{2}$"), pl.col("filing_date") >= "1800-01-01")
data = patents.sort("filing_date", descending=True, nulls_last=True).collect()
# %% 2. Check inputs
assert data.height > 0, "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip returned no rows"
# %% 3. Prepare data
# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing. Names that clean alike are numbered as janitor
# numbers them (Indicator, indicator -> indicator, indicator_2).
clean_names = [
re.sub(
r"[^0-9a-z]+",
"_",
re.sub(
r"([a-z0-9])([A-Z])",
r"\1_\2",
unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
).lower(),
).strip("_")
for column in data.columns
]
while len(set(clean_names)) < len(clean_names):
name_counts = {}
numbered = []
for name in clean_names:
name_counts[name] = name_counts.get(name, 0) + 1
count = name_counts[name]
numbered.append(name if count == 1 else f"{name}_{count}")
clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))Stata
* ============================================================
* Canadian patent search (CIPO IP Horizons)
* Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
* (exact: the IP Horizons tables the search used, then the tool's filters)
* Inputs: https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
* Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/ised_ip_horizons_search_patents.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
import re
import ssl
import zipfile
from pathlib import Path
import certifi
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "PT_main_1_to_2000000_2024-10-11.zip"
intermediate = httpx.get("http://cacerts.rapidssl.com/RapidSSLTLSRSACAG1.crt").content
tls = ssl.create_default_context(cafile=certifi.where())
tls.load_verify_locations(cadata=ssl.DER_cert_to_PEM_cert(intermediate))
files = {'main_1': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip', 'main_2': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip'}
tables = {}
exec('with httpx.Client(verify=tls, timeout=900, follow_redirects=True) as client:\n for name, url in files.items():\n zip_path = RAW_DIR / f"{name}.zip"\n with client.stream("GET", url) as response, zip_path.open("wb") as handle:\n response.raise_for_status()\n for chunk in response.iter_bytes(1 << 20):\n handle.write(chunk)\n with zipfile.ZipFile(zip_path) as archive:\n member = archive.namelist()[0]\n archive.extract(member, RAW_DIR)\n tables[name] = (pl.scan_csv(RAW_DIR / member, separator="|", quote_char=None, null_values=["NULL"], infer_schema=False) .rename(lambda column: re.sub(r"[^a-z0-9]+", "_", column.split(" - ")[0].strip().lower()).strip("_")) .with_columns(pl.all().str.strip_chars()))\n')
patents = pl.concat([tables[name] for name in ['main_1', 'main_2']]).filter(pl.col("filing_date").str.contains('^\\d{4}-\\d{2}-\\d{2}\x24'), pl.col("filing_date") >= "1800-01-01")
data = patents.sort("filing_date", descending=True, nulls_last=True).collect()
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "PT_main_1_to_2000000_202_prepared.csv")
end
import delimited "data/raw/PT_main_1_to_2000000_202_prepared.csv", clear varnames(1) encoding("utf-8")
* 2. Check inputs
assert _N > 0
* 3. Prepare data
* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.
local names
foreach var of varlist _all {
local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
local numbered
local before
foreach name of local names {
local count 1
foreach earlier of local before {
if "`earlier'" == "`name'" local ++count
}
local before `before' `name'
if `count' > 1 {
local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
}
local numbered `numbered' `name'
}
local names `numbered'
local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
local ++i
local name : word `i' of `names'
if "`name'" != "`var'" {
local old_names `old_names' `var'
local new_names `new_names' `name'
}
}
if "`old_names'" != "" {
rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
}
destring, replace
log closeJulia
# ============================================================
# Canadian patent search (CIPO IP Horizons)
# Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
# (exact: the IP Horizons tables the search used, then the tool's filters)
# Inputs: https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
# Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
# ============================================================
# 0. Setup
using CSV
using DataFrames
using Downloads
using TidierData
using ZipFile
mkpath("data/raw")
# 1. Read inputs
files = Dict(
"main_1" => "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip",
"main_2" => "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip",
)
tables = Dict{String, DataFrame}()
for (name, url) in files
zip_path = "data/raw/$(name).zip"
Downloads.download(url, zip_path)
archive = ZipFile.Reader(zip_path)
member = archive.files[1]
csv_path = "data/raw/$(member.name)"
write(csv_path, read(member))
close(archive)
# Every column as text; keep the English half of each bilingual header.
table = CSV.read(csv_path, DataFrame; delim = '|', quoted = false, missingstring = "NULL", types = String)
rename!(table, [lowercase(replace(strip(split(n, " - ")[1]), r"[^A-Za-z0-9]+" => "_")) for n in names(table)])
tables[name] = mapcols(col -> [ismissing(x) ? missing : strip(x) for x in col], table)
end
patents = vcat([tables[n] for n in ["main_1", "main_2"]]...)
patents = filter(row -> (!ismissing(row.filing_date) && occursin(r"^\d{4}-\d{2}-\d{2}$", row.filing_date) && row.filing_date >= "1800-01-01"), patents)
data = sort(patents, "filing_date"; rev = true)
# 2. Check inputs
@assert nrow(data) > 0 "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip returned no rows"
# 3. Prepare data
# Standard cleaning: snake_case names, trimmed text, empty strings as missing.
data = @chain data begin
@clean_names
end
data = mapcols(
col -> eltype(col) <: Union{Missing, AbstractString} ?
[ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
data,
)15,937
Bank of Canada series for the financial analysts, doomed to explain the rate,
The policy rate since 2015
The Bank of Canada's target for the overnight rate on every business day since January 2015: 29 changes, from a low of 0.25% to a high of 5.00%. On 6 October 2026 it stood at 2.25%.
Show the data
| Date | Rate |
|---|---|
| 1 January 2015 | 1.00% |
| 21 January 2015 | 0.75% |
| 15 July 2015 | 0.50% |
| 12 July 2017 | 0.75% |
| 6 September 2017 | 1.00% |
| 17 January 2018 | 1.25% |
| 11 July 2018 | 1.50% |
| 24 October 2018 | 1.75% |
| 4 March 2020 | 1.25% |
| 16 March 2020 | 0.75% |
| 27 March 2020 | 0.25% |
| 3 March 2022 | 0.50% |
| 14 April 2022 | 1.00% |
| 2 June 2022 | 1.50% |
| 14 July 2022 | 2.50% |
| 8 September 2022 | 3.25% |
| 27 October 2022 | 3.75% |
| 8 December 2022 | 4.25% |
| 26 January 2023 | 4.50% |
| 8 June 2023 | 4.75% |
| 13 July 2023 | 5.00% |
| 6 June 2024 | 4.75% |
| 25 July 2024 | 4.50% |
| 5 September 2024 | 4.25% |
| 24 October 2024 | 3.75% |
| 12 December 2024 | 3.25% |
| 30 January 2025 | 3.00% |
| 13 March 2025 | 2.75% |
| 18 September 2025 | 2.50% |
| 30 October 2025 | 2.25% |
| 6 October 2026 | 2.25% |
The calls behind this
call_tool{"name": "boc_get_observations", "arguments": {"series_names": ["V39079"], "start_date": "2015-01-01"}}R
# ============================================================
# Bank of Canada Valet: V39079
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
# (exact: Valet request rebuilt from the tool's arguments)
# Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================
# 0. Setup ----
library(dplyr)
library(httr2)
library(janitor)
library(jsonlite)
library(lubridate)
library(purrr)
library(readr)
library(stringr)
library(tibble)
library(tidyr)
dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)
# 1. Read inputs ----
response <- request("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01") |>
req_perform()
writeLines(resp_body_string(response), "data/raw/valet_observations.json")
payload <- fromJSON("data/raw/valet_observations.json", flatten = TRUE)
data <- as_tibble(payload[["observations"]])
# 2. Check inputs ----
stopifnot("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows" = nrow(data) > 0)
# 3. Prepare data ----
data <- data |>
clean_names()
# Valet nests each series as <series>.v; make one row per date and series.
data <- data |>
pivot_longer(-d, names_to = "series", values_to = "value") |>
mutate(
series = str_remove(series, "_v$") |> str_to_upper(),
value = as.numeric(value),
date = ymd(d)
) |>
select(date, series, value)
# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns <- "^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$"
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), "")))
keep_text <- names(data)[
str_detect(names(data), code_columns) |
map_lgl(
data,
\(column) is.character(column) && any(str_detect(column, "^-?0[0-9]"), na.rm = TRUE)
)
]
data <- data |>
mutate(
across(any_of(keep_text), \(x) if (is.list(x)) x else as.character(x)),
across(where(is.character) & !any_of(keep_text), parse_guess)
)Python
# ============================================================
# Bank of Canada Valet: V39079
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
# (exact: Valet request rebuilt from the tool's arguments)
# Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================
# %% 0. Setup
import json
import re
import unicodedata
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"
# %% 1. Read inputs
with httpx.Client(
http2=True,
follow_redirects=True,
timeout=300,
headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
response = client.get('https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)
payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)
# %% 2. Check inputs
assert data.height > 0, "https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows"
# %% 3. Prepare data
# Valet nests each series as <series>.v; make one row per date and series.
data = data.unpivot(index="d", variable_name="series", value_name="value")
data = data.with_columns(
pl.col("d").str.to_date().alias("date"),
pl.col("series").str.replace(r"\.v$", ""),
pl.col("value").cast(pl.Float64, strict=False),
).select("date", "series", "value")
# Standard cleaning: snake_case names as janitor's clean_names() gives them
# in R (PÉRIODE -> periode, referenceNumber -> reference_number, # -> number,
# % -> percent, apostrophes dropped, a leading digit prefixed with x),
# trimmed text, empty strings as missing. Names that clean alike are
# numbered as janitor numbers them (Indicator, indicator -> indicator,
# indicator_2).
clean_names = [
re.sub(
r"[^0-9a-z]+",
"_",
re.sub(
r"([a-z0-9])([A-Z])",
r"\1_\2",
"".join(
" " if unicodedata.category(ch)[0] in "PS" and not ch.isascii() else ch
for ch in unicodedata.normalize("NFKD", column.replace("'", ""))
)
.replace("#", " number ")
.replace("%", " percent ")
.encode("ascii", "ignore")
.decode(),
).lower(),
).strip("_")
or "x"
for column in data.columns
]
clean_names = [f"x{name}" if name[0].isdigit() else name for name in clean_names]
while len(set(clean_names)) < len(clean_names):
name_counts = {}
numbered = []
for name in clean_names:
name_counts[name] = name_counts.get(name, 0) + 1
count = name_counts[name]
numbered.append(name if count == 1 else f"{name}_{count}")
clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))
# Numbers stored as text become numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns = re.compile(r"^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$")
for column in data.columns:
dtype = data.schema[column]
if code_columns.search(column) and not dtype.is_nested():
data = data.with_columns(pl.col(column).cast(pl.Utf8))
continue
if dtype != pl.Utf8 or data[column].null_count() == data.height:
continue
numbers = data[column].cast(pl.Float64, strict=False)
leading_zero = data[column].str.contains(r"^-?0[0-9]").any()
if numbers.null_count() > data[column].null_count() or leading_zero:
continue
whole = data[column].cast(pl.Int64, strict=False)
data = data.with_columns(whole if whole.null_count() == numbers.null_count() else numbers)Stata
* ============================================================
* Bank of Canada Valet: V39079
* Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
* (exact: Valet request rebuilt from the tool's arguments)
* Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
* Outputs: data/raw/valet_observations.json; the prepared table as `data`
* ============================================================
version 18
clear all
set more off
* 0. Setup
capture mkdir "logs"
capture log close
log using "logs/boc_get_observations.log", replace
capture mkdir "data"
capture mkdir "data/raw"
* 1. Read inputs
* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.
python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)
payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "valet_observations_prepared.csv")
end
import delimited "data/raw/valet_observations_prepared.csv", clear varnames(1) encoding("utf-8") stringcols(_all) bindquote(strict) maxquotedrows(unlimited)
* 2. Check inputs
assert _N > 0
* 3. Prepare data
* One column per series (<series>_v), one row per date.
generate date = date(d, "YMD")
format date %td
drop d
* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone). Codes
* stay text as the source wrote them: variables named like one (coordinate,
* vectorid, dguid, NAICS, postal code) and ones with a leading zero (01).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.
local names
foreach var of varlist _all {
local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
local numbered
local before
foreach name of local names {
local count 1
foreach earlier of local before {
if "`earlier'" == "`name'" local ++count
}
local before `before' `name'
if `count' > 1 {
local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
}
local numbered `numbered' `name'
}
local names `numbered'
local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
local ++i
local name : word `i' of `names'
if "`name'" != "`var'" {
local old_names `old_names' `var'
local new_names `new_names' `name'
}
}
if "`old_names'" != "" {
rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
if regexm("`var'|", "^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)[|]") continue
quietly count if regexm(`var', "^-?0[0-9]")
if r(N) > 0 continue
destring `var', replace
}
log closeJulia
# ============================================================
# Bank of Canada Valet: V39079
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
# (exact: Valet request rebuilt from the tool's arguments)
# Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================
# 0. Setup
using DataFrames
using Dates
using Downloads
using JSON3
using Tables
using TidierData
mkpath("data/raw")
# 1. Read inputs
Downloads.download("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01", "data/raw/valet_observations.json")
payload = JSON3.read(read("data/raw/valet_observations.json", String))
records = payload["observations"]
data = DataFrame(Tables.dictrowtable(records))
data = mapcols(col -> [x === nothing ? missing : x for x in col], data)
# 2. Check inputs
@assert nrow(data) > 0 "https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows"
# 3. Prepare data
# Valet nests each series as {v: value}; make one row per date and series.
data = stack(data, Not("d"); variable_name = "series", value_name = "cell")
data.value = [
ismissing(cell) ? missing :
something(tryparse(Float64, string(get(cell, :v, ""))), missing)
for cell in data.cell
]
data.date = Date.(data.d)
data = select(data, "date", "series", "value")
# Standard cleaning: snake_case names, trimmed text, empty strings as missing.
data = @chain data begin
@clean_names
end
data = mapcols(
col -> eltype(col) <: Union{Missing, AbstractString} ?
[ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
data,
)
# Numbers stored as text become numbers. Codes stay text as the source
# wrote them: columns named like one (coordinate, vector_id, dguid, NAICS,
# postal code) and columns with a leading zero (01, 000000001).
code_columns = r"^(coordinate|vector|vector_?id|product_?id|pid|dguid|[a-z0-9_]*_dguid|postal_?code|[a-z0-9_]*_postal_code|fsa|[a-z0-9_]*naics[a-z0-9_]*|noc|noc_[a-z0-9_]*|[a-z0-9_]*_noc|sgc|sgc_[a-z0-9_]*|[a-z0-9_]*_sgc)$"
for name in names(data)
column = data[!, name]
if occursin(code_columns, name)
data[!, name] = [ismissing(x) ? missing : string(x) for x in column]
continue
end
eltype(column) <: Union{Missing, AbstractString} || continue
values = collect(skipmissing(column))
(isempty(values) || any(v -> occursin(r"^-?0[0-9]", v), values)) && continue
any(v -> isnothing(tryparse(Float64, v)), values) && continue
whole = all(v -> !isnothing(tryparse(Int, v)), values)
data[!, name] = [
ismissing(x) ? missing : parse(whole ? Int : Float64, x) for x in column
]
endExcel (Power Query)
// ============================================================
// Bank of Canada Valet: V39079
// Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
// (exact: Valet request rebuilt from the tool's arguments)
// Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
// Outputs: the prepared table, loaded to a worksheet or the Data Model
// How to run: Excel > Data > Get Data > From Other Sources > Blank Query >
// Advanced Editor; paste this query, Done, then Close & Load. Answer the
// first prompt with Anonymous access; Data > Refresh All fetches it again.
// A worksheet holds 1,048,576 rows: load a larger table (a full StatCan
// table can be) with Close & Load To > Only Create Connection > Add this
// data to the Data Model.
// ============================================================
let
// 0. Setup
// Cell values as text or number, null-safe, for the filters and checks.
AsText = (value as any) as text =>
if value = null then "" else (try Text.From(value) otherwise ""),
AsNumber = (value as any) as nullable number =>
if value = null then null else (try Number.From(value, "en-US") otherwise null),
// Records to a table with every field any record has, not only the first's.
ToTable = (records as list) as table =>
Table.FromRecords(
records,
List.Distinct(List.Combine(List.Transform(records, Record.FieldNames))),
MissingField.UseNull
),
// 1. Read inputs
Raw = Web.Contents("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01"),
Payload = Json.Document(Raw),
Loaded = ToTable(Record.Field(Payload, "observations")),
// 2. Check inputs
// Stop with a clear message instead of loading an empty table.
Checked = if Table.IsEmpty(Loaded)
then error Error.Record("MapleStats", "https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows")
else Loaded,
// 3. Prepare data
// Valet nests each series as <series>[v]; make one row per date and series.
Long = Table.UnpivotOtherColumns(Checked, {"d"}, "series", "cell"),
Valued = Table.AddColumn(
Long, "value", each try Number.From([cell][v], "en-US") otherwise null, type number
),
Tidy = Table.RenameColumns(Table.SelectColumns(Valued, {"d", "series", "value"}), {{"d", "date"}}),
// Standard cleaning, as the R script's clean_names() and type_convert():
// snake_case names without accents (PÉRIODE -> periode, referenceNumber ->
// reference_number), names that clean alike numbered (indicator,
// indicator_2), trimmed text, empty text as null, then columns whose
// every value is a number or an ISO date (YYYY-MM-DD) converted.
Accents = [#"à" = "a", #"â" = "a", #"ä" = "a", #"á" = "a", #"ç" = "c", #"é" = "e", #"è" = "e", #"ê" = "e", #"ë" = "e", #"î" = "i", #"ï" = "i", #"í" = "i", #"ô" = "o", #"ö" = "o", #"ó" = "o", #"ù" = "u", #"û" = "u", #"ü" = "u", #"ú" = "u", #"ÿ" = "y", #"ñ" = "n", #"œ" = "oe", #"æ" = "ae", #"À" = "A", #"Â" = "A", #"Ä" = "A", #"Á" = "A", #"Ç" = "C", #"É" = "E", #"È" = "E", #"Ê" = "E", #"Ë" = "E", #"Î" = "I", #"Ï" = "I", #"Í" = "I", #"Ô" = "O", #"Ö" = "O", #"Ó" = "O", #"Ù" = "U", #"Û" = "U", #"Ü" = "U", #"Ú" = "U", #"Ÿ" = "Y", #"Ñ" = "N", #"Œ" = "OE", #"Æ" = "AE"],
CleanName = (name as text) as text =>
let
Plain = Text.Combine(
List.Transform(Text.ToList(name), (ch) => Record.FieldOrDefault(Accents, ch, ch))
),
Words = Splitter.SplitTextByCharacterTransition({"a".."z", "0".."9"}, {"A".."Z"})(
Plain
),
Lower = Text.Lower(Text.Combine(Words, "_")),
Spaced = Text.Combine(
List.Transform(
Text.ToList(Lower), (ch) => if List.Contains(NameCharacters, ch) then ch else " "
)
),
Snake = Text.Combine(List.Select(Text.Split(Spaced, " "), (part) => part <> ""), "_")
in
if Snake = "" then "x" else Snake,
NameCharacters = {"a".."z", "0".."9"},
OldNames = Table.ColumnNames(Tidy),
CleanNames = List.Transform(OldNames, CleanName),
NewNames = List.Transform(
List.Positions(CleanNames),
(i) =>
let
Earlier = List.Count(
List.Select(List.FirstN(CleanNames, i), (name) => name = CleanNames{i})
)
in
if Earlier = 0 then CleanNames{i} else CleanNames{i} & "_" & Text.From(Earlier + 1)
),
Renamed = Table.RenameColumns(
Tidy,
List.Select(List.Zip({OldNames, NewNames}), (pair) => pair{0} <> pair{1})
),
Trimmed = Table.TransformColumns(
Renamed,
List.Transform(
Table.ColumnNames(Renamed),
(name) =>
{
name,
(value) =>
if value is text
then (if Text.Trim(value) = "" then null else Text.Trim(value))
else value
}
)
),
IsNumberText = (value as any) as logical =>
value is number
or (value is text and (try Number.FromText(value, "en-US") otherwise null) <> null),
IsIsoDate = (value as any) as logical =>
value is text
and Text.Length(value) = 10
and Text.Middle(value, 4, 1) = "-"
and Text.Middle(value, 7, 1) = "-"
and (try Date.FromText(value, "en-US") otherwise null) <> null,
ColumnsWhere = (table as table, test as function) as list =>
List.Select(
Table.ColumnNames(table),
(name) =>
let
Values = List.RemoveNulls(Table.Column(table, name))
in
not List.IsEmpty(Values) and List.AllTrue(List.Transform(Values, test))
),
// Codes stay text as the source wrote them: columns named like one
// (coordinate, vector_id, dguid, NAICS, postal code; StatCan's "2.1" and
// "2.10" are different coordinates) and columns with a leading zero (01).
CodeName = (name as text) as logical =>
List.Contains(
{"coordinate", "vector", "vector_id", "vectorid", "product_id", "productid", "pid",
"dguid", "postal_code", "postalcode", "fsa", "noc", "sgc"},
name
)
or Text.EndsWith(name, "_dguid")
or Text.EndsWith(name, "_postal_code")
or Text.Contains(name, "naics")
or Text.StartsWith(name, "noc_")
or Text.EndsWith(name, "_noc")
or Text.StartsWith(name, "sgc_")
or Text.EndsWith(name, "_sgc"),
HasLeadingZero = (value as any) as logical =>
value is text
and (
let
Digits = if Text.StartsWith(value, "-") then Text.Middle(value, 1) else value
in
Text.Length(Digits) > 1
and Text.Start(Digits, 1) = "0"
and List.Contains({"0".."9"}, Text.Middle(Digits, 1, 1))
),
CodeColumns = List.Select(
Table.ColumnNames(Trimmed),
(name) => CodeName(name) or List.AnyTrue(List.Transform(Table.Column(Trimmed, name), HasLeadingZero))
),
Codes = Table.TransformColumns(
Trimmed,
List.Transform(
CodeColumns,
(name) =>
{
name,
(value) =>
if value = null then null
else if value is number then Number.ToText(value, "G", "en-US")
else Text.From(value),
type text
}
)
),
NumberColumns = List.Difference(ColumnsWhere(Codes, IsNumberText), CodeColumns),
Numbers = Table.TransformColumnTypes(
Codes, List.Transform(NumberColumns, (name) => {name, type number}), "en-US"
),
DateColumns = ColumnsWhere(Numbers, IsIsoDate),
Cleaned = Table.TransformColumns(
Numbers,
List.Transform(
DateColumns,
(name) => {name, (value) => if value = null then null else Date.FromText(value, "en-US"), type date}
)
),
Data = Cleaned
in
DataOne MCP for the Agent to find them and bring them all together.
Show the data
| Subject | Tools |
|---|---|
| Provinces and cities: open-data catalogues, provincial, municipal | 117 |
| Statistics: statistics and census | 92 |
| Land and energy: agriculture and food, environment and hazards, energy and mining, geography, transport and safety | 55 |
| People: health, housing, immigration | 40 |
| Money and business: money, prices and public finance, business, IP and competition | 30 |
| Parliament: parliament, law and elections | 21 |
Data access and discovery are no longer the bottleneck.
Never pay for numbers Canada already publishes for free. Ask the question, get the number with its source and a script that fetches it again, and spend your time on what you actually want to do: the model, the argument, the decision.
MCP stands for Model Context Protocol, an open standard for connecting AI agents to tools and data. Think of it as a USB-C port for AI: any agent that speaks it can plug into any server that does. MapleStats is that plug for Canadian data: 175 sources from federal agencies, provinces, cities and open-data catalogues, one connection.
What one connection reaches
- 1,610,458patents
- 962,449census records in one tabulation
- 15,937Bank of Canada series
- 8,271Statistics Canada tables
- 158microdata files
- 98credit cards
- 67current IRCC tables
- 54CMHC data categories