home_care_cost_model 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 716517c40320a0ed76c334a26fef5cf72a9534972887e730631d2e2f88a1e474
4
+ data.tar.gz: dd9841247a970e010e71978237b2706620e8190fca2f7348f5275b2669aed48d
5
+ SHA512:
6
+ metadata.gz: ceb31de0cef84981656e648a6320720a499acf7b773e2c574b7ecfd9e1c74480719711b1091af5a6e9774470fe0dcc722e6b444f2117ae78bd5e2714757af340
7
+ data.tar.gz: bf88e68c8a03e2c28b59cf70f69c4c73ec14b6a32fb3cd5d51bf16fb1724c0a3fbb9e9bc7ecc59938a476037f939797600ec85662282f31f33334589604537a1
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
3
+ require "home_care_cost_model"
4
+ require "json"
5
+
6
+ result = HomeCareCostModel.calculate(
7
+ adl_katz_score: 2,
8
+ iadl_lawton_score: 1,
9
+ province: "BC",
10
+ household_composition: "with_spouse",
11
+ cognitive_status: "moderate",
12
+ mobility_status: "walker",
13
+ primary_diagnosis_category: "dementia",
14
+ informal_caregiver_hours_per_week: 20.0,
15
+ net_family_income_cad: 72000.0,
16
+ has_dtc: true,
17
+ )
18
+
19
+ if ARGV.include?("--json")
20
+ puts JSON.pretty_generate(result)
21
+ else
22
+ puts "Home Care Cost Model — Ruby Engine v#{HomeCareCostModel::VERSION}"
23
+ puts "======================================================"
24
+ puts "Jurisdiction: #{result[:province]} Tax year: #{result[:tax_year]}"
25
+ puts "Recommended PSW hours/week: #{result[:recommended_psw_hours_per_week]}"
26
+ puts "Recommended housekeeping h/w: #{result[:recommended_housekeeping_hours_per_week]}"
27
+ puts "Recommended nursing h/w: #{result[:recommended_nursing_hours_per_week]}"
28
+ puts "Service mix: #{result[:recommended_service_mix]}"
29
+ puts "Private pay monthly CAD: $#{'%.2f' % result[:private_pay_monthly_cad]}"
30
+ puts "Subsidy value monthly CAD: $#{'%.2f' % result[:subsidy_value_monthly_cad]}"
31
+ puts "OoP before credits monthly CAD: $#{'%.2f' % result[:out_of_pocket_before_credits_monthly_cad]}"
32
+ puts "Tax credits annual CAD: $#{'%.2f' % result[:total_credits_value_cad]}"
33
+ puts "OoP after credits monthly CAD: $#{'%.2f' % result[:out_of_pocket_after_credits_monthly_cad]}"
34
+ puts "OoP after credits annual CAD: $#{'%.2f' % result[:out_of_pocket_after_credits_annual_cad]}"
35
+ puts "All-PSW comparison monthly CAD: $#{'%.2f' % result[:all_psw_cost_comparison_monthly_cad]}"
36
+ puts "Hybrid savings monthly CAD: $#{'%.2f' % result[:hybrid_savings_vs_all_psw_monthly_cad]}"
37
+ puts
38
+ puts "Disclaimer: #{result[:disclaimer]}"
39
+ end
@@ -0,0 +1,250 @@
1
+ # Home Care Cost Model — Ruby reference port
2
+ #
3
+ # Reference cost model for Canadian home care service-mix decisions.
4
+ # Port of the Python reference implementation.
5
+ #
6
+ # Working paper: https://www.binx.ca/guides/home-care-cost-model-guide.pdf
7
+ # This reference model is not clinical or financial advice.
8
+
9
+ require "csv"
10
+ require "json"
11
+
12
+ module HomeCareCostModel
13
+ VERSION = "0.1.0"
14
+ WEEKS_PER_MONTH = 4.345
15
+ DISCLAIMER = "Reference model only. Not clinical or financial advice; consult a regulated health professional or registered tax practitioner for individual decisions."
16
+
17
+ METC_FEDERAL_RATE = 0.15
18
+ METC_FLOOR_FRAC = 0.03
19
+ METC_ABS_FLOOR = 2759.0
20
+ DTC_BASE = 9872.0
21
+ CCC_CAREGIVER_BASE = 8375.0
22
+ CCC_PHASE_START = 19666.0
23
+ CCC_PHASE_END = 28041.0
24
+ VIP_HOUSEKEEPING = 3072.0
25
+ VIP_PERSONAL_CARE = 9324.0
26
+
27
+ PROVINCIAL_FACTORS = {
28
+ "ON" => { metc_rate: 0.0505, metc_floor: 2923, dtc_base: 10250, ccc_base: 5933 },
29
+ "QC" => { metc_rate: 0.1400, metc_floor: 2759, dtc_base: 3494, ccc_base: 1311 },
30
+ "BC" => { metc_rate: 0.0506, metc_floor: 2605, dtc_base: 9428, ccc_base: 5014 },
31
+ "AB" => { metc_rate: 0.1000, metc_floor: 2824, dtc_base: 16066, ccc_base: 12307 },
32
+ "SK" => { metc_rate: 0.1050, metc_floor: 2837, dtc_base: 10405, ccc_base: 10405 },
33
+ "MB" => { metc_rate: 0.1080, metc_floor: 1728, dtc_base: 6180, ccc_base: 3605 },
34
+ "NS" => { metc_rate: 0.0879, metc_floor: 1637, dtc_base: 7341, ccc_base: 4898 },
35
+ "NB" => { metc_rate: 0.0940, metc_floor: 2344, dtc_base: 8552, ccc_base: 5197 },
36
+ "NL" => { metc_rate: 0.0870, metc_floor: 2116, dtc_base: 7064, ccc_base: 3497 },
37
+ "PE" => { metc_rate: 0.0980, metc_floor: 1768, dtc_base: 6890, ccc_base: 2446 },
38
+ "YT" => { metc_rate: 0.0640, metc_floor: 2759, dtc_base: 9872, ccc_base: 8375 },
39
+ "NT" => { metc_rate: 0.0590, metc_floor: 2759, dtc_base: 14160, ccc_base: 5167 },
40
+ "NU" => { metc_rate: 0.0400, metc_floor: 2759, dtc_base: 15440, ccc_base: 5560 },
41
+ }.freeze
42
+
43
+ def self.personal_care_category_for(province)
44
+ case province
45
+ when "ON", "QC" then "PSW"
46
+ when "BC", "AB", "SK", "MB" then "HCA"
47
+ else "HSW"
48
+ end
49
+ end
50
+
51
+ def self.cognitive_bump(c)
52
+ { "intact" => 0, "mild" => 3, "moderate" => 8, "severe" => 14 }[c] || 0
53
+ end
54
+
55
+ def self.mobility_bump(m)
56
+ { "independent" => 0, "cane" => 0.5, "walker" => 2, "wheelchair" => 5, "bedbound" => 12 }[m] || 0
57
+ end
58
+
59
+ def self.household_mod(h)
60
+ { "alone" => 3, "with_spouse" => 1.5, "with_adult_child" => 1, "multigen" => 0.5 }[h] || 2
61
+ end
62
+
63
+ def self.diagnosis_nursing(d)
64
+ { "stroke" => 4, "parkinson" => 1, "post_surgical" => 6, "chronic_mixed" => 2 }[d] || 0
65
+ end
66
+
67
+ def self.derive_psw_hours(adl, cognitive, mobility, informal)
68
+ base = 7.0 * [0, 6 - adl].max + cognitive_bump(cognitive) + mobility_bump(mobility)
69
+ credited = [informal * 0.5, base * 0.6].min
70
+ [(base - credited), 0.0].max.round(1)
71
+ end
72
+
73
+ def self.derive_housekeeping_hours(iadl, household)
74
+ base = 2.0 * [0, 8 - iadl].max + household_mod(household)
75
+ [base, 0.0].max.round(1)
76
+ end
77
+
78
+ def self.derive_nursing_hours(diagnosis, cognitive)
79
+ base = diagnosis_nursing(diagnosis)
80
+ base += 1 if cognitive == "severe"
81
+ [base, 0.0].max.round(1)
82
+ end
83
+
84
+ def self.datasets_dir
85
+ candidates = [
86
+ File.expand_path("../../datasets", __dir__),
87
+ File.expand_path("../../../datasets", __dir__),
88
+ File.expand_path("./datasets"),
89
+ ]
90
+ candidates.find { |d| File.exist?(File.join(d, "home_care_services_canada.csv")) }
91
+ end
92
+
93
+ def self.load_services
94
+ path = File.join(datasets_dir, "home_care_services_canada.csv") rescue nil
95
+ return {} unless path && File.exist?(path)
96
+ result = {}
97
+ CSV.foreach(path, headers: true) do |row|
98
+ result["#{row["jurisdiction_code"]}|#{row["service_category"]}"] = row.to_h
99
+ end
100
+ result
101
+ end
102
+
103
+ def self.load_subsidies
104
+ path = File.join(datasets_dir, "home_care_subsidy_programs.csv") rescue nil
105
+ return {} unless path && File.exist?(path)
106
+ result = {}
107
+ CSV.foreach(path, headers: true) do |row|
108
+ code = row["jurisdiction_code"]
109
+ result[code] ||= row.to_h
110
+ end
111
+ result
112
+ end
113
+
114
+ def self.service_rate(services, prov, category)
115
+ row = services["#{prov}|#{category}"]
116
+ return 0.0 unless row
117
+ (row["private_pay_rate_cad_median"] || "0").to_f
118
+ end
119
+
120
+ def self.subsidy_hours_awarded(subsidies, prov, adl)
121
+ row = subsidies[prov]
122
+ return 0.0 unless row
123
+ moderate = (row["typical_psw_hours_per_week_moderate"] || "0").to_f
124
+ high = (row["typical_psw_hours_per_week_high"] || "0").to_f
125
+ return 0.0 if adl >= 5
126
+ return high.round(1) if adl <= 1
127
+ alpha = (4 - adl) / 3.0
128
+ (moderate + alpha * (high - moderate)).round(1)
129
+ end
130
+
131
+ def self.calculate(input)
132
+ input = {
133
+ household_composition: "alone",
134
+ cognitive_status: "intact",
135
+ mobility_status: "independent",
136
+ primary_diagnosis_category: "frailty",
137
+ informal_caregiver_hours_per_week: 0.0,
138
+ net_family_income_cad: 60000.0,
139
+ is_veteran: false,
140
+ has_dtc: false,
141
+ agency_vs_private: "private",
142
+ include_subsidy: true,
143
+ tax_year: 2026,
144
+ }.merge(input)
145
+
146
+ services = load_services
147
+ subsidies = load_subsidies
148
+
149
+ psw_hours = derive_psw_hours(
150
+ input[:adl_katz_score], input[:cognitive_status],
151
+ input[:mobility_status], input[:informal_caregiver_hours_per_week]
152
+ )
153
+ housekeeping_hours = derive_housekeeping_hours(input[:iadl_lawton_score], input[:household_composition])
154
+ nursing_hours = derive_nursing_hours(input[:primary_diagnosis_category], input[:cognitive_status])
155
+
156
+ mix = if nursing_hours > 0 && psw_hours > 0
157
+ housekeeping_hours > 0 ? "nursing+psw+housekeeping" : "nursing+psw"
158
+ elsif psw_hours > 0 && housekeeping_hours > 0
159
+ "psw+housekeeping"
160
+ elsif psw_hours > 0
161
+ "psw_only"
162
+ elsif housekeeping_hours > 0
163
+ "housekeeping_only"
164
+ else
165
+ "no_formal_services"
166
+ end
167
+
168
+ pc_cat = personal_care_category_for(input[:province])
169
+ psw_rate = service_rate(services, input[:province], pc_cat)
170
+ house_cat = input[:agency_vs_private] == "agency" ? "Cleaning_Service_Agency" : "Housekeeper_Private"
171
+ housekeeping_rate = service_rate(services, input[:province], house_cat)
172
+ nursing_rate = service_rate(services, input[:province], "LPN_RPN")
173
+
174
+ private_monthly = (psw_hours * psw_rate + housekeeping_hours * housekeeping_rate + nursing_hours * nursing_rate) * WEEKS_PER_MONTH
175
+
176
+ subsidy_hours = input[:include_subsidy] ? [subsidy_hours_awarded(subsidies, input[:province], input[:adl_katz_score]), psw_hours].min : 0.0
177
+ subsidy_value = subsidy_hours * psw_rate * WEEKS_PER_MONTH
178
+ oop_monthly = [private_monthly - subsidy_value, 0.0].max
179
+ oop_annual = oop_monthly * 12.0
180
+
181
+ pp = PROVINCIAL_FACTORS[input[:province]] || PROVINCIAL_FACTORS["ON"]
182
+ fed_threshold = [input[:net_family_income_cad] * METC_FLOOR_FRAC, METC_ABS_FLOOR].min
183
+ metc_fed = [(oop_annual - fed_threshold), 0.0].max * METC_FEDERAL_RATE
184
+ prov_threshold = [input[:net_family_income_cad] * METC_FLOOR_FRAC, pp[:metc_floor]].min
185
+ metc_prov = [(oop_annual - prov_threshold), 0.0].max * pp[:metc_rate]
186
+ metc_credit = metc_fed + metc_prov
187
+
188
+ dtc_credit = (input[:has_dtc] || input[:cognitive_status] == "severe") ? (DTC_BASE * METC_FEDERAL_RATE + pp[:dtc_base] * pp[:metc_rate]) : 0.0
189
+
190
+ ccc_credit = 0.0
191
+ if %w[with_spouse with_adult_child multigen].include?(input[:household_composition]) && psw_hours >= 10
192
+ phase_factor = if input[:net_family_income_cad] <= CCC_PHASE_START
193
+ 1.0
194
+ elsif input[:net_family_income_cad] >= CCC_PHASE_END
195
+ 0.0
196
+ else
197
+ 1.0 - (input[:net_family_income_cad] - CCC_PHASE_START) / (CCC_PHASE_END - CCC_PHASE_START)
198
+ end
199
+ ccc_credit = (CCC_CAREGIVER_BASE * METC_FEDERAL_RATE + pp[:ccc_base] * pp[:metc_rate]) * phase_factor
200
+ end
201
+
202
+ vip_credit = input[:is_veteran] ? (VIP_HOUSEKEEPING + VIP_PERSONAL_CARE) : 0.0
203
+ total_credits = metc_credit + dtc_credit + ccc_credit + vip_credit
204
+ oop_after_annual = [oop_annual - total_credits, 0.0].max
205
+ oop_after_monthly = oop_after_annual / 12.0
206
+
207
+ total_hours = psw_hours + housekeeping_hours + nursing_hours
208
+ all_psw_monthly = total_hours * psw_rate * WEEKS_PER_MONTH
209
+ hybrid_savings = all_psw_monthly - private_monthly
210
+
211
+ scope_warnings = []
212
+ if input[:adl_katz_score] <= 4 || %w[moderate severe].include?(input[:cognitive_status])
213
+ scope_warnings << "Personal care required: the recipient's ADL score or cognitive status indicates that personal care tasks (bathing, toileting, transfers) are needed. A housekeeper or cleaning service cannot legally substitute for a PSW/HCA in this scope. If a cleaning service is part of the plan, it must be in addition to, not instead of, personal support."
214
+ end
215
+
216
+ employment_warnings = []
217
+ if input[:agency_vs_private] == "private" && (total_hours - subsidy_hours) >= 20
218
+ employment_warnings << "At #{(total_hours - subsidy_hours).round(0)} hours per week of privately-hired care in #{input[:province]}, the CRA employer-determination test is likely to classify the family as an employer."
219
+ end
220
+
221
+ {
222
+ province: input[:province],
223
+ tax_year: input[:tax_year],
224
+ recommended_psw_hours_per_week: psw_hours,
225
+ recommended_housekeeping_hours_per_week: housekeeping_hours,
226
+ recommended_nursing_hours_per_week: nursing_hours,
227
+ recommended_service_mix: mix,
228
+ psw_hourly_rate_cad: psw_rate.round(2),
229
+ housekeeping_hourly_rate_cad: housekeeping_rate.round(2),
230
+ nursing_hourly_rate_cad: nursing_rate.round(2),
231
+ private_pay_monthly_cad: private_monthly.round(2),
232
+ subsidy_hours_per_week_allocated: subsidy_hours.round(1),
233
+ subsidy_value_monthly_cad: subsidy_value.round(2),
234
+ out_of_pocket_before_credits_monthly_cad: oop_monthly.round(2),
235
+ metc_credit_value_cad: metc_credit.round(2),
236
+ dtc_credit_value_cad: dtc_credit.round(2),
237
+ ccc_credit_value_cad: ccc_credit.round(2),
238
+ vac_vip_credit_value_cad: vip_credit.round(2),
239
+ total_credits_value_cad: total_credits.round(2),
240
+ out_of_pocket_after_credits_monthly_cad: oop_after_monthly.round(2),
241
+ out_of_pocket_after_credits_annual_cad: oop_after_annual.round(2),
242
+ all_psw_cost_comparison_monthly_cad: all_psw_monthly.round(2),
243
+ hybrid_savings_vs_all_psw_monthly_cad: hybrid_savings.round(2),
244
+ scope_warnings: scope_warnings,
245
+ employment_law_warnings: employment_warnings,
246
+ currency: "CAD",
247
+ disclaimer: DISCLAIMER,
248
+ }
249
+ end
250
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: home_care_cost_model
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dave Cook
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-04-10 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Ruby port of the Home Care Cost Model reference implementation. Calculates
13
+ recommended PSW, housekeeping, and nursing hours, private-pay cost, subsidised hours,
14
+ and the 2026 federal/provincial tax relief stack for Canadian households.
15
+ email:
16
+ - dave@binx.ca
17
+ executables:
18
+ - home-care-cost-model
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - bin/home-care-cost-model
23
+ - lib/home_care_cost_model.rb
24
+ homepage: https://github.com/DaveCookVectorLabs/home-care-cost-model
25
+ licenses:
26
+ - MIT
27
+ metadata:
28
+ homepage_uri: https://github.com/DaveCookVectorLabs/home-care-cost-model
29
+ source_code_uri: https://github.com/DaveCookVectorLabs/home-care-cost-model
30
+ documentation_uri: https://www.binx.ca/guides/home-care-cost-model-guide.pdf
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.7.0
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.6.3
46
+ specification_version: 4
47
+ summary: Reference cost model for Canadian home care service-mix decisions.
48
+ test_files: []