Case studies
Eight crafts, one connection
What can you do with MapleStats? Eight questions, from eight 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(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", "--"), show_col_types = FALSE)
# 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",
`EN_YEAR` >= 2015
)
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
# ============================================================
# 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_length=100_000, 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 without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing.
data = data.rename(
{
column: 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
}
)
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))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_length=100_000, null_values=['--'])
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")
* 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
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
replace `var' = strtrim(`var')
}
destring, 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 DataFrames
using Downloads
using TidierData
using TidierFiles
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 = read_csv("data/raw/ODP-PR-PT_CMA.csv", delim = "\t", 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"]) && row["EN_YEAR"] >= 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,
)54
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.txt; the downloaded file
# ============================================================
# 0. Setup ----
library(httr2)
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.txt")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.txt; the downloaded file
# ============================================================
# %% 0. Setup
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.txt"
# %% 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)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.txt; the downloaded file
* ============================================================
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 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.txt"
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)
end
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.txt; the downloaded file
# ============================================================
# 0. Setup
using Downloads
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.txt",
)962,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), in Canadian dollars and without the student and secured cards, which are separate searches there: its annual fee against its purchase interest rate. 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,434
days of bond yields for the macroeconomists, reading the curve before the turn,
When the yield curve turns upside down
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. From 24 in 2005 to a peak of 667 in 2021. The counts come from the newest IP Horizons bulk release when the calls were recorded, dated 11 October 2024. 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 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 24 September 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% |
| 24 September 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(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.
data <- data |>
mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
type_convert()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 without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing.
data = data.rename(
{
column: 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
}
)
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))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")
* 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
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: 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 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))
# 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
# 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,
)One MCP for the Agent to find them, one MCP to bring them all together.
Show the data
| Subject | Tools |
|---|---|
| Statistics: statistics and census | 62 |
| Provinces and cities: open-data catalogues, provincial, municipal | 47 |
| Money and business: money, prices and public finance, business, IP and competition | 31 |
| Land and energy: agriculture and food, environment and hazards, energy, geography, transport and safety | 31 |
| People: health, housing, immigration | 22 |
| Parliament: parliament, law and elections | 20 |
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: 215 tools from federal agencies, provinces, cities and open-data catalogues, behind one connection.
What one connection reaches
- 1,610,458patents
- 962,449census records
- 15,937Bank of Canada series
- 8,271Statistics Canada tables
- 158microdata files
- 98credit cards
- 67immigration tables
- 54housing tables