SHOW:
|
|
- or go back to the newest paste.
1 | # Data sources: | |
2 | # New study permits: 40k-60k | |
3 | # New work permits: 280k-360k | |
4 | # Study permits (new): capped at 437k for 2025. H1 likely 40k-60k new approvals (cap + seasonality). | |
5 | # Work permits (new): ~825k finalized Jan-Jul, but 40-50% are renewals. H1 ~280k-360k new approvals. | |
6 | # Combined H1 2025 “new” study+work ≈ 320k-420k. | |
7 | # StatsCan shows NPR stock fell in Q1 2025, meaning exits/extensions outweighed new arrivals. | |
8 | # https://www.canada.ca/en/immigration-refugees-citizenship/corporate/mandate/corporate-initiatives/levels/inventories-backlogs.html | |
9 | # https://www.canada.ca/en/immigration-refugees-citizenship/news/notices/2025-provincial-territorial-allocations-under-international-student-cap.html https://open.canada.ca/data/en/dataset/9b34e712-513f-44e9-babf-9df4f7256550 | |
10 | # https://open.canada.ca/data/en/dataset/90115b00-f9b8-49e8-afa3-b4cff8facaee | |
11 | # https://open.canada.ca/data/en/dataset/360024f2-17e9-4558-bfc1-3616485d65b9 | |
12 | # https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1710012101 | |
13 | # https://www150.statcan.gc.ca/n1/daily-quotidien/250618/dq250618a-eng.htm | |
14 | # Births: https://www150.statcan.gc.ca/t1/tbl1/en/dtl!downloadDbLoadingData-nonTraduit.action?pid=1310041501&latestN=0&startDate=20000101&endDate=99990101&csvLocale=en&selectedMembers=%5B%5B1%5D%2C%5B%5D%2C%5B1%5D%5D&checkedLevels=1D1%2C1D2 (https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1310041501) - 351,878 | |
15 | # Deaths: https://www150.statcan.gc.ca/t1/tbl1/en/dtl!downloadDbLoadingData-nonTraduit.action?pid=1310070801&latestN=0&startDate=20000101&endDate=99990101&csvLocale=en&selectedMembers=%5B%5B1%5D%2C%5B1%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13%5D%2C%5B1%2C2%5D%5D&checkedLevels= (https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1310070801) - 326,215 | |
16 | ||
17 | import matplotlib.pyplot as plt | |
18 | from matplotlib.lines import Line2D | |
19 | ||
20 | TOTAL_POP = 41_290_000 | |
21 | BIRTHS = 351_878 | |
22 | DEATHS = 326_215 | |
23 | OBSERVED_POP_CHANGE = 20_107 | |
24 | NATURAL_INCREASE = BIRTHS - DEATHS | |
25 | ||
26 | NEW_STUDY_RANGE = (40_000, 60_000) | |
27 | NEW_WORK_RANGE = (280_000, 360_000) | |
28 | RENEWED_WORK_RANGE = (330_000, 410_000) | |
29 | ||
30 | STUDENT_EXPIRY_SHARE_RANGE = (0.30, 0.40) | |
31 | WORK_EXPIRY_SHARE_RANGE = (0.60, 0.70) | |
32 | ||
33 | def midpoint(lo: int, hi: int) -> float: | |
34 | return (lo + hi) / 2.0 | |
35 | ||
36 | def halfspan(lo: int, hi: int) -> float: | |
37 | return (hi - lo) / 2.0 | |
38 | ||
39 | NEW_VISAS_RANGE = (NEW_STUDY_RANGE[0] + NEW_WORK_RANGE[0], | |
40 | NEW_STUDY_RANGE[1] + NEW_WORK_RANGE[1]) | |
41 | ||
42 | EXPIRIES_TOTAL_RANGE = ( | |
43 | NATURAL_INCREASE + NEW_VISAS_RANGE[0] + RENEWED_WORK_RANGE[0] - OBSERVED_POP_CHANGE, | |
44 | NATURAL_INCREASE + NEW_VISAS_RANGE[1] + RENEWED_WORK_RANGE[1] - OBSERVED_POP_CHANGE | |
45 | ) | |
46 | ||
47 | EXPIRING_STUDENTS_RANGE = ( | |
48 | int(EXPIRIES_TOTAL_RANGE[0] * STUDENT_EXPIRY_SHARE_RANGE[0]), | |
49 | int(EXPIRIES_TOTAL_RANGE[1] * STUDENT_EXPIRY_SHARE_RANGE[1]), | |
50 | ) | |
51 | EXPIRING_WORK_RANGE = ( | |
52 | int(EXPIRIES_TOTAL_RANGE[0] * WORK_EXPIRY_SHARE_RANGE[0]), | |
53 | int(EXPIRIES_TOTAL_RANGE[1] * WORK_EXPIRY_SHARE_RANGE[1]), | |
54 | ) | |
55 | ||
56 | categories = [ | |
57 | "Births", | |
58 | "Deaths", | |
59 | - | "New Visas (study+work)", |
59 | + | "New Study Visas", |
60 | "New Work Visas", | |
61 | "Renewed Work Permits", | |
62 | "Expiring Student Visas", | |
63 | "Expiring Work Permits", | |
64 | "Population Change", | |
65 | ] | |
66 | ||
67 | values = [ | |
68 | float(BIRTHS), | |
69 | - | midpoint(*NEW_VISAS_RANGE), |
69 | + | |
70 | midpoint(*NEW_STUDY_RANGE), | |
71 | midpoint(*NEW_WORK_RANGE), | |
72 | midpoint(*RENEWED_WORK_RANGE), | |
73 | -midpoint(*EXPIRING_STUDENTS_RANGE), | |
74 | -midpoint(*EXPIRING_WORK_RANGE), | |
75 | float(OBSERVED_POP_CHANGE), | |
76 | ] | |
77 | ||
78 | yerr = [ | |
79 | - | halfspan(*NEW_VISAS_RANGE), |
79 | + | |
80 | 0.0, | |
81 | halfspan(*NEW_STUDY_RANGE), | |
82 | halfspan(*NEW_WORK_RANGE), | |
83 | halfspan(*RENEWED_WORK_RANGE), | |
84 | halfspan(*EXPIRING_STUDENTS_RANGE), | |
85 | halfspan(*EXPIRING_WORK_RANGE), | |
86 | 0.0, | |
87 | - | "#2ca02c", "#d62728", "#1f77b4", |
87 | + | |
88 | - | "#9467bd", "#ff7f0e", "#8c564b", "#7f7f7f" |
88 | + | |
89 | colors = [ | |
90 | "#2ca02c", # Births | |
91 | - | plt.figure(figsize=(13, 7)) |
91 | + | "#d62728", # Deaths |
92 | "#17becf", # New Study (teal) | |
93 | "#1f77b4", # New Work (blue) | |
94 | "#9467bd", # Renewed Work (purple) | |
95 | "#ff7f0e", # Expiring Student (orange) | |
96 | "#8c564b", # Expiring Work (brown) | |
97 | "#7f7f7f", # Pop change (grey) | |
98 | ] | |
99 | ||
100 | plt.figure(figsize=(14, 7.5)) | |
101 | bars = plt.bar(categories, values, color=colors) | |
102 | ||
103 | for i, (v, e) in enumerate(zip(values, yerr)): | |
104 | if e > 0: | |
105 | plt.errorbar(i, v, yerr=e, fmt='none', capsize=6, elinewidth=1.5, ecolor='black') | |
106 | plt.scatter([i], [v], s=40, marker='D', edgecolor='black', zorder=3) | |
107 | ||
108 | pad = 120_000 | |
109 | for bar, v, e in zip(bars, values, yerr): | |
110 | x = bar.get_x() + bar.get_width() / 2 | |
111 | va = 'bottom' if v >= 0 else 'top' | |
112 | if e > 0: | |
113 | lo, hi = v - e, v + e | |
114 | txt = f"central estimate*: {int(round(v)):,}\nrange: {int(round(lo)):,}-{int(round(hi)):,}" | |
115 | else: | |
116 | txt = f"{int(round(abs(v))):,}" | |
117 | y = v + (pad if v >= 0 else -pad) | |
118 | plt.text(x, y, txt, ha='center', va=va, fontsize=9) | |
119 | ||
120 | def add_group_bracket(ax, x0, x1, y, label, invert=False): | |
121 | - | nv_idx, rw_idx = 2, 3 |
121 | + | |
122 | - | expS_idx, expW_idx = 4, 5 |
122 | + | |
123 | ax.text((x0+x1)/2, y+55_000, label, ha='center', va='bottom', fontsize=10) | |
124 | - | y_nv = values[nv_idx] + yerr[nv_idx] |
124 | + | |
125 | ax.plot([x0, x0, x1, x1], [y, y-40_000, y-40_000, y], lw=1.5, color='black') | |
126 | - | y_expS = values[expS_idx] - yerr[expS_idx] |
126 | + | |
127 | ||
128 | ax = plt.gca() | |
129 | - | y_group_top = max(y_nv, y_rw) + 200_000 |
129 | + | |
130 | - | sum_new_renewed = int(round(values[nv_idx] + values[rw_idx])) |
130 | + | ns_idx, nw_idx, rw_idx = 2, 3, 4 |
131 | - | add_group_bracket(ax, nv_idx, rw_idx, y_group_top, f"New+Renewed visas (sum): {sum_new_renewed:,}") |
131 | + | expS_idx, expW_idx = 5, 6 |
132 | ||
133 | - | y_group_bottom = min(y_expS, y_expW) - 220_000 |
133 | + | y_ns = values[ns_idx] + yerr[ns_idx] |
134 | - | sum_expiries = int(round(abs(values[expS_idx] + values[expW_idx]))) |
134 | + | y_nw = values[nw_idx] + yerr[nw_idx] |
135 | - | add_group_bracket(ax, expS_idx, expW_idx, y_group_bottom, f"All expiries (sum): {sum_expiries:,}", invert=True) |
135 | + | |
136 | y_expS = values[expS_idx] - yerr[expS_idx] # negative | |
137 | y_expW = values[expW_idx] - yerr[expW_idx] | |
138 | ||
139 | y_group_new = max(y_ns, y_nw) + 180_000 | |
140 | sum_new = int(round(values[ns_idx] + values[nw_idx])) | |
141 | add_group_bracket(ax, ns_idx, nw_idx, y_group_new, f"New visas (Study+Work sum): {sum_new:,}") | |
142 | ||
143 | y_group_new_renewed = max(y_group_new + 220_000, max(y_ns, y_nw, y_rw) + 260_000) | |
144 | - | plt.title("Population Components: Canada (H1 2025)\nRanges shown with central estimate*", pad=16) |
144 | + | sum_new_renewed = int(round(values[ns_idx] + values[nw_idx] + values[rw_idx])) |
145 | add_group_bracket(ax, ns_idx, rw_idx, y_group_new_renewed, f"New (Study+Work) + Renewed Work (sum): {sum_new_renewed:,}") | |
146 | ||
147 | - | upper = max((v + e) for v, e in zip(values, yerr)) * 1.7 + 200_000 |
147 | + | y_group_exp = min(y_expS, y_expW) - 220_000 |
148 | - | lower = min((v - e) for v, e in zip(values, yerr)) * 1.7 - 200_000 |
148 | + | sum_exp = int(round(abs(values[expS_idx] + values[expW_idx]))) |
149 | add_group_bracket(ax, expS_idx, expW_idx, y_group_exp, f"All expiries (sum): {sum_exp:,}", invert=True) | |
150 | ||
151 | legend_elems = [ | |
152 | Line2D([0], [0], color='black', lw=1.5, label='Range (vertical line)'), | |
153 | Line2D([0], [0], marker='D', color='w', markeredgecolor='black', label='Central estimate*', markersize=7), | |
154 | ] | |
155 | plt.legend(handles=legend_elems, loc='upper right') | |
156 | ||
157 | plt.axhline(0, linewidth=0.9, color='black') | |
158 | plt.title("Population Components: Canada (H1 2025)\nNew visas split into Study vs Work; ranges shown with central estimate*", pad=16) | |
159 | plt.ylabel("Count (people)") | |
160 | - | plt.show() |
160 | + | |
161 | upper = max((v + e) for v, e in zip(values, yerr)) * 1.75 + 200_000 | |
162 | - | |
162 | + | lower = min((v - e) for v, e in zip(values, yerr)) * 1.75 - 250_000 |
163 | plt.ylim(lower, upper) | |
164 | ||
165 | plt.xticks(rotation=10) | |
166 | plt.tight_layout() | |
167 | ||
168 | plt.figtext( | |
169 | 0.5, 0.01, | |
170 | "*Estimate based on IRCC/StatCan data and a heuristic expiry split (30-40% students, 60-70% workers).", | |
171 | ha="center", fontsize=9, style="italic" | |
172 | ) | |
173 | ||
174 | plt.show() |