From 5affa0af96d594bb9078bfca2456b4076832e01b Mon Sep 17 00:00:00 2001 From: Ziming Date: Mon, 12 Oct 2026 14:24:42 -0400 Subject: [PATCH 1/7] Add Ohio state supplementation for 2026 --- changelog.d/oh-ssp-2026.added.md | 1 + .../household/household_state_benefits.yaml | 4 + .../hud/annual_income/sources/unearned.yaml | 1 + .../gov/states/oh/dbh/rss/allowable_fee.yaml | 13 ++ .../gov/states/oh/dbh/rss/minimum_age.yaml | 9 + .../oh/dbh/rss/personal_needs_allowance.yaml | 13 ++ .../usda/snap/income/sources/unearned.yaml | 7 +- policyengine_us/programs.yaml | 12 ++ ...si_state_supplement_medicaid_dependency.py | 23 ++- .../gov/states/oh/dbh/rss/integration.yaml | 193 ++++++++++++++++++ .../gov/states/oh/dbh/rss/oh_rss.yaml | 171 ++++++++++++++++ ...icaid_enrolled_for_ssi_state_supplement.py | 35 ++-- .../variables/gov/states/oh/dbh/rss/oh_rss.py | 32 +++ .../gov/states/oh/dbh/rss/oh_rss_certified.py | 10 + .../oh/dbh/rss/oh_rss_countable_income.py | 47 +++++ .../oh/dbh/rss/oh_rss_days_of_placement.py | 12 ++ .../gov/states/oh/dbh/rss/oh_rss_eligible.py | 19 ++ .../income/spm_unit/spm_unit_benefits.py | 1 + 18 files changed, 580 insertions(+), 23 deletions(-) create mode 100644 changelog.d/oh-ssp-2026.added.md create mode 100644 policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml create mode 100644 policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml create mode 100644 policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml create mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py create mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py create mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py create mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py create mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py diff --git a/changelog.d/oh-ssp-2026.added.md b/changelog.d/oh-ssp-2026.added.md new file mode 100644 index 00000000000..dfd805223e8 --- /dev/null +++ b/changelog.d/oh-ssp-2026.added.md @@ -0,0 +1 @@ +Add Ohio Residential State Supplement for 2026. diff --git a/policyengine_us/parameters/gov/household/household_state_benefits.yaml b/policyengine_us/parameters/gov/household/household_state_benefits.yaml index d2cbd6ba2d4..34c79b74e8c 100644 --- a/policyengine_us/parameters/gov/household/household_state_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_state_benefits.yaml @@ -78,6 +78,7 @@ values: # DC benefits - dc_ossp # Kansas benefits + - oh_rss - ks_sspp # Hawaii benefits - hi_oss @@ -176,6 +177,7 @@ values: # DC benefits - dc_ossp # Kansas benefits + - oh_rss - ks_sspp # Hawaii benefits - hi_oss @@ -279,6 +281,7 @@ values: - dc_ossp - dc_child_care_subsidies # Kansas benefits + - oh_rss - ks_sspp # Hawaii benefits - hi_oss @@ -382,6 +385,7 @@ values: - dc_ossp - dc_child_care_subsidies # Kansas benefits + - oh_rss - ks_sspp # Hawaii benefits - hi_oss diff --git a/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml b/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml index d2b2bbd8675..b7ca9abf659 100644 --- a/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml @@ -19,6 +19,7 @@ values: - strike_benefits - social_security - ssi + - oh_rss - general_assistance - unemployment_compensation - child_support_received diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml new file mode 100644 index 00000000000..5e3e99eadf4 --- /dev/null +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml @@ -0,0 +1,13 @@ +description: Ohio provides this monthly allowable facility fee under its Residential State Supplement + program. +values: + '2023-07-01': 1600 +metadata: + unit: currency-USD + label: Ohio RSS allowable facility fee + reference: + - title: Ohio Administrative Code 5122-36-05(B)-(C) + href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Medicaid Eligibility Procedure Letter 175 + href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 + period: month diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml new file mode 100644 index 00000000000..587c49101f8 --- /dev/null +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml @@ -0,0 +1,9 @@ +description: Ohio limits its Residential State Supplement to people this age or older. +values: + '2022-09-03': 18 +metadata: + unit: year + label: Ohio RSS minimum age + reference: + - title: Ohio Administrative Code 5122-36-02(A)(1) + href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml new file mode 100644 index 00000000000..17af56d5c37 --- /dev/null +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml @@ -0,0 +1,13 @@ +description: Ohio provides this monthly personal needs allowance under its Residential State Supplement + program. +values: + '2023-07-01': 200 +metadata: + unit: currency-USD + label: Ohio RSS personal needs allowance + reference: + - title: Ohio Administrative Code 5122-36-05(B)-(C) + href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Medicaid Eligibility Procedure Letter 175 + href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 + period: month diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml index 6f94d3d09b2..49bd2c22fd4 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml @@ -36,6 +36,7 @@ values: - hi_oss - id_aabd - in_ssp + - oh_rss - ks_sspp - ky_ssp - la_oss @@ -95,9 +96,7 @@ values: metadata: reference: - - title: 7 CFR 273.9(b)(2) - href: https://www.law.cornell.edu/cfr/text/7/273.9#b_2 - - title: 7 CFR 273.9(c)(1) - href: https://www.law.cornell.edu/cfr/text/7/273.9#c_1 + - title: 7 CFR 273.9(b)(2) and (c)(1), (5) + href: https://www.law.cornell.edu/cfr/text/7/273.9 - title: POMS SI 01401.001 General Information about State Supplementation href: https://secure.ssa.gov/poms.nsf/lnx/0501401001 diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 05cb1d13747..432675fab7a 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1090,6 +1090,18 @@ programs: tracks. Unmodeled paths: grandfathered medically-needy-in-medical- institution recipients per WAC 388-478-0055(2)(b); DDA-determined / PVL track per (2)(f); and foster child in BRS per (2)(g). + - state: OH + status: partial + name: Ohio RSS + full_name: Ohio Residential State Supplement + variable: oh_rss + parameter_prefix: gov.states.oh.dbh.rss + verified_years: '2026' + notes: Models approved full-month placements and first-month proration. Nonfinancial + care certification and Medicaid enrollment must be supplied when not determined by + the model. Specialized work-expense and PASS exclusions absent from the federal SSI + model require a countable-income override; no ineligible-spouse or parent income is + deemed. - id: social_security name: Social Security diff --git a/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py b/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py index a64c80a17a7..0ac611e783c 100644 --- a/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py +++ b/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py @@ -23,6 +23,8 @@ def situation(year, state="IN"): "is_snap_workfare_participant": {year: True}, }, } + if state == "OH": + people["recipient"]["oh_rss_certified"] = {year: True} return { "people": people, **{ @@ -35,11 +37,14 @@ def situation(year, state="IN"): } -@pytest.mark.parametrize("year", [2027, 2030, 2035]) -@pytest.mark.parametrize("state,benefit", [("IN", "in_ssp"), ("KS", "ks_sspp")]) +@pytest.mark.parametrize("year", [2026, 2027, 2030, 2035]) +@pytest.mark.parametrize( + "state,benefit", + [("IN", "in_ssp"), ("KS", "ks_sspp"), ("OH", "oh_rss")], +) def test_ssp_snap_medicaid_calculation_order(year, state, benefit): """YAML cannot express repeated calculations in different orders.""" - variables = (benefit, "snap", "medicaid_enrolled") + variables = (benefit, "snap", "medicaid_enrolled", "hud_unearned_income") results = [] for order in (variables, variables[::-1]): simulation = Simulation(situation=situation(year, state)) @@ -54,7 +59,10 @@ def test_ssp_snap_medicaid_calculation_order(year, state, benefit): assert results[0]["snap"][1] > 0 # Kansas does not cover this childless adult through Medicaid expansion; # SNAP pass-through does not replace Medicaid's category requirement. - assert results[0]["medicaid_enrolled"].tolist() == [True, state == "IN"] + assert results[0]["medicaid_enrolled"].tolist() == [ + True, + state in {"IN", "OH", "UT"}, + ] @pytest.mark.parametrize( @@ -62,7 +70,12 @@ def test_ssp_snap_medicaid_calculation_order(year, state, benefit): ["medicaid_enrolled", "is_medicaid_eligible", "takes_up_medicaid_if_eligible"], ) @pytest.mark.parametrize( - "state,eligibility", [("IN", "in_ssp_sapn_eligible"), ("KS", "ks_sspp_eligible")] + "state,eligibility", + [ + ("IN", "in_ssp_sapn_eligible"), + ("KS", "ks_sspp_eligible"), + ("OH", "oh_rss_eligible"), + ], ) def test_ssp_preserves_medicaid_inputs_set_after_initialization( variable, state, eligibility diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml new file mode 100644 index 00000000000..24e306648ea --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml @@ -0,0 +1,193 @@ +- name: Case 1, both spouses in approved care. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + second: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + marital_units: + couple: + members: + - first + - second + tax_units: + unit: + members: + - first + - second + households: + household: + members: + - first + - second + state_code: OH + output: + oh_rss: + - 1054.5 + - 1054.5 +- name: Case 2, one spouse in approved care. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + second: + age: 65 + marital_units: + couple: + members: + - first + - second + tax_units: + unit: + members: + - first + - second + households: + household: + members: + - first + - second + state_code: OH + output: + oh_rss: + - 1054.5 + - 0 +- name: Case 3, ineligible spouse income is not deemed. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + second: + age: 40 + social_security_retirement: 60000 + marital_units: + couple: + members: + - first + - second + tax_units: + unit: + members: + - first + - second + households: + household: + members: + - first + - second + state_code: OH + output: + oh_rss: + - 1800 + - 0 +- name: Case 4, combined resource boundary. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 2000 + second: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 1000 + marital_units: + couple: + members: + - first + - second + tax_units: + unit: + members: + - first + - second + households: + household: + members: + - first + - second + state_code: OH + output: + oh_rss: + - 1054.5 + - 1054.5 +- name: Case 5, combined excess resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 2000 + second: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 1001 + marital_units: + couple: + members: + - first + - second + tax_units: + unit: + members: + - first + - second + households: + household: + members: + - first + - second + state_code: OH + output: + oh_rss: + - 0 + - 0 +- name: Case 6, annual benefits and SNAP income. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + output: + oh_rss: 9672 + household_state_benefits: 9672 + snap_unearned_income: 21600 + hud_unearned_income: 21600 +- name: Case 7, partial month daily amount rounded upward. + period: 2026-04 + absolute_error_margin: 0.01 + input: + state_code: + 2026: OH + age: + 2026: 65 + oh_rss_certified: true + medicaid_enrolled: + 2026: true + oh_rss_days_of_placement: 15 + output: + oh_rss: 403.05 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml new file mode 100644 index 00000000000..d41e3064420 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml @@ -0,0 +1,171 @@ +- name: Case 1, full SSI. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + output: + oh_rss: 806 +- name: Case 2, non SSI Social Security. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + social_security_retirement: 18000 + output: + oh_rss: 320 +- name: Case 3, income exactly at threshold. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + social_security_retirement: 21840 + output: + oh_rss: 0 +- name: Case 4, income just below threshold. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + social_security_retirement: 21828 + output: + oh_rss: 1 +- name: Case 5, income above threshold. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + social_security_retirement: 24000 + output: + oh_rss: 0 +- name: Case 6, earned income exclusion. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + employment_income: 2220 + output: + oh_rss: 806 +- name: Case 7, no care certification. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: false + medicaid_enrolled: true + output: + oh_rss: 0 +- name: Case 8, no Medicaid. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: false + output: + oh_rss: 0 +- name: Case 9, age seventeen. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 17 + oh_rss_certified: true + medicaid_enrolled: true + meets_ssi_disability_criteria: true + output: + oh_rss: 0 +- name: Case 10, age eighteen disabled. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 18 + oh_rss_certified: true + medicaid_enrolled: true + meets_ssi_disability_criteria: true + output: + oh_rss: 806 +- name: Case 11, not aged blind or disabled. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 40 + oh_rss_certified: true + medicaid_enrolled: true + output: + oh_rss: 0 +- name: Case 12, resource boundary. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 2000 + output: + oh_rss: 806 +- name: Case 13, excess resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 2001 + output: + oh_rss: 0 +- name: Case 14, out of state. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: IN + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + output: + oh_rss: 0 +- name: Case 15, partial first month sixteen days. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + oh_rss_days_of_placement: 16 + output: + oh_rss: 416 +- name: Case 16, zero days of placement. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + oh_rss_days_of_placement: 0 + output: + oh_rss: 0 diff --git a/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py b/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py index d02f1625c4e..4721d768617 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py @@ -14,7 +14,7 @@ class medicaid_enrolled_for_ssi_state_supplement(Variable): ) documentation = ( "Medicaid enrollment condition for SSI/aged/blind/disabled state " - "supplement candidates only (Indiana SAPN/RCAP and Kansas SSPP). " + "supplement candidates only (Indiana, Kansas and Ohio). " "SSI recipients and aged, blind, or disabled RCAP candidates do not " "need the SNAP/TANF community-engagement exclusion. Other consumers " "must use medicaid_enrolled, which retains that exclusion." @@ -22,30 +22,37 @@ class medicaid_enrolled_for_ssi_state_supplement(Variable): def formula(person, period, parameters): p = parameters(period).gov.hhs.medicaid.eligibility.work_requirements - if not p.applies: - return person("medicaid_enrolled", period) - simulation = person.simulation enrolled = simulation.get_array("medicaid_enrolled", period) if enrolled is not None: return enrolled - # SNAP counts these state supplements as income, so asking for receipt - # status here would recurse. SSP candidates qualify through SSI or - # age/blindness/disability independently of SNAP/TANF. Keep all other - # Medicaid conditions, take-up and supplied inputs in a private branch. + # SNAP counts supplements as income, so its work-requirement + # exemption cannot determine Medicaid for SSI/ABD supplement candidates. + # HUD also counts supplements. Medicaid's optional tax-filing check can + # reach HUD through rent-related tax deductions. While determining + # enrollment, withhold the supplements that require that enrollment; + # evaluate their actual grants only in the original simulation. SSI/ABD + # Medicaid eligibility does not depend on those housing tax deductions. + # Preserve all other Medicaid conditions, take-up and supplied inputs. branch_name = f"{simulation.branch_name}_ssi_state_supplement_medicaid_{period}" + overrides = { + "medicaid_enrolled_for_ssi_state_supplement": ( + period, + np.zeros(person.count, dtype=bool), + ) + } + if p.applies: + overrides["medicaid_community_engagement_pass_through_eligible"] = ( + period.first_month, + np.zeros(person.count, dtype=bool), + ) try: branch = get_override_branch( simulation, branch_name, period, - { - "medicaid_community_engagement_pass_through_eligible": ( - period.first_month, - np.zeros(person.count, dtype=bool), - ) - }, + overrides, ) return branch.calculate("medicaid_enrolled", period) finally: diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py new file mode 100644 index 00000000000..cd78ac9bc14 --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class oh_rss(Variable): + value_type = float + entity = Person + label = "Ohio Residential State Supplement" + definition_period = MONTH + unit = USD + defined_for = "oh_rss_eligible" + reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" + + def formula(person, period, parameters): + p = parameters(period).gov.states.oh.dbh.rss + limits = parameters(period).gov.ssa.ssi.eligibility.resources.limit + eligible = person("oh_rss_eligible", period) + couple = person.marital_unit.sum(eligible) == 2 + resources = person("ssi_countable_resources", period.this_year) + resources = where(couple, person.marital_unit.sum(resources), resources) + limit = where(couple, limits.couple, limits.individual) + payment = max_( + p.allowable_fee + + p.personal_needs_allowance + - person("oh_rss_countable_income", period), + 0, + ) + days = clip(person("oh_rss_days_of_placement", period), 0, period.days) + # First-month placement is prorated after rounding the daily payment + # upward to the cent. Full months retain the unprorated benefit. + daily_payment = np.ceil(payment.astype(np.float64) / period.days * 100) / 100 + partial = np.ceil(daily_payment * days * 100 - 1e-8) / 100 + return (resources <= limit) * where(days == period.days, payment, partial) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py new file mode 100644 index 00000000000..d615ea08aad --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class oh_rss_certified(Variable): + value_type = bool + entity = Person + label = "Ohio RSS certified care placement" + definition_period = MONTH + documentation = "Ohio has approved the current RSS placement and protective level of care, including the facility, supervision, waiver, PACE, hospice and provider-relationship conditions in rule 5122-36-02. Includes a state-approved temporary institutional continuation under 5160:1-5-01(H)." + reference = "https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36" diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py new file mode 100644 index 00000000000..eec123ac193 --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py @@ -0,0 +1,47 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) + + +class oh_rss_countable_income(Variable): + value_type = float + entity = Person + label = "Ohio RSS countable income" + definition_period = MONTH + unit = USD + reference = ( + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-03.2", + ) + + def formula(person, period, parameters): + # RSS does not deem income from an ineligible spouse or parent. + eligible = person("oh_rss_eligible", period) + couple = person.marital_unit.sum(eligible) == 2 + earned = max_( + person("ssi_earned_income", period) + - person("ssi_blind_or_disabled_working_student_exclusion", period), + 0, + ) + # Interest and dividends are excluded under 5160:1-3-03.2(D)(2)(v). + unearned = max_( + person("ssi_unearned_income", period) + - person("interest_income", period) + - person("dividend_income", period), + 0, + ) + earned = where(couple, person.marital_unit.sum(earned), earned) + unearned = where(couple, person.marital_unit.sum(unearned), unearned) + federal_ssi = person("ssi", period) + federal_ssi = where(couple, person.marital_unit.sum(federal_ssi), federal_ssi) + # Apply exclusions once per couple. SSI is needs-based income and does + # not receive the general $20 disregard (D)(2)(l). + countable = ( + _apply_ssi_exclusions( + earned * MONTHS_IN_YEAR, unearned * MONTHS_IN_YEAR, parameters, period + ) + / MONTHS_IN_YEAR + + federal_ssi + ) + return where(couple, countable / 2, countable) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py new file mode 100644 index 00000000000..faf4570903e --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py @@ -0,0 +1,12 @@ +from policyengine_us.model_api import * + + +class oh_rss_days_of_placement(Variable): + value_type = int + entity = Person + label = "Days of Ohio RSS placement in the month" + definition_period = MONTH + reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" + + def formula(person, period, parameters): + return period.days diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py new file mode 100644 index 00000000000..a5c3eaafb2e --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class oh_rss_eligible(Variable): + value_type = bool + entity = Person + label = "Meets Ohio RSS categorical and placement requirements" + definition_period = MONTH + defined_for = StateCode.OH + reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" + + def formula(person, period, parameters): + p = parameters(period).gov.states.oh.dbh.rss + return ( + person("oh_rss_certified", period) + & (person("age", period.this_year) >= p.minimum_age) + & person("is_ssi_aged_blind_disabled", period.this_year) + & person("medicaid_enrolled_for_ssi_state_supplement", period.this_year) + ) diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py index 41ead044f46..24bfff223e2 100644 --- a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py @@ -22,6 +22,7 @@ def formula(spm_unit, period, parameters): "ky_ssp", # Kentucky benefits "de_ssp", # Delaware benefits "fl_oss", + "oh_rss", "ks_sspp", # Kansas benefits "hi_oss", "la_oss", # Louisiana benefits From dc3ef37882b38ea3ecde3a69fa4b0876f7a92e6a Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 13 Oct 2026 01:01:30 -0400 Subject: [PATCH 2/7] Normalize Ohio SSP parameter and test formatting --- .../household/household_state_benefits.yaml | 1 + .../hud/annual_income/sources/unearned.yaml | 1 + .../gov/states/oh/dbh/rss/allowable_fee.yaml | 16 +-- .../gov/states/oh/dbh/rss/minimum_age.yaml | 7 +- .../oh/dbh/rss/personal_needs_allowance.yaml | 16 +-- .../gov/states/oh/dbh/rss/integration.yaml | 124 +++++++----------- .../gov/states/oh/dbh/rss/oh_rss.yaml | 29 +++- 7 files changed, 89 insertions(+), 105 deletions(-) diff --git a/policyengine_us/parameters/gov/household/household_state_benefits.yaml b/policyengine_us/parameters/gov/household/household_state_benefits.yaml index 34c79b74e8c..f389e5c2e9b 100644 --- a/policyengine_us/parameters/gov/household/household_state_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_state_benefits.yaml @@ -405,6 +405,7 @@ values: - nj_property_tax_relief # New York benefits - ny_child_care_subsidies + metadata: unit: list period: year diff --git a/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml b/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml index b7ca9abf659..a49d0e66a4e 100644 --- a/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml @@ -30,6 +30,7 @@ values: - disability_benefits - survivor_benefits - financial_assistance + metadata: unit: list period: year diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml index 5e3e99eadf4..d77ab4a2e03 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml @@ -1,13 +1,13 @@ -description: Ohio provides this monthly allowable facility fee under its Residential State Supplement - program. +description: Ohio provides this monthly allowable facility fee under its Residential State Supplement program. values: - '2023-07-01': 1600 + 2023-07-01: 1_600 + metadata: unit: currency-USD + period: month label: Ohio RSS allowable facility fee reference: - - title: Ohio Administrative Code 5122-36-05(B)-(C) - href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 - - title: Ohio Medicaid Eligibility Procedure Letter 175 - href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 - period: month + - title: Ohio Administrative Code 5122-36-05(B)-(C) + href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Medicaid Eligibility Procedure Letter 175 + href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml index 587c49101f8..901a86bf647 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml @@ -1,9 +1,10 @@ description: Ohio limits its Residential State Supplement to people this age or older. values: - '2022-09-03': 18 + 2022-09-03: 18 + metadata: unit: year label: Ohio RSS minimum age reference: - - title: Ohio Administrative Code 5122-36-02(A)(1) - href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Administrative Code 5122-36-02(A)(1) + href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml index 17af56d5c37..401b4fc5a78 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml @@ -1,13 +1,13 @@ -description: Ohio provides this monthly personal needs allowance under its Residential State Supplement - program. +description: Ohio provides this monthly personal needs allowance under its Residential State Supplement program. values: - '2023-07-01': 200 + 2023-07-01: 200 + metadata: unit: currency-USD + period: month label: Ohio RSS personal needs allowance reference: - - title: Ohio Administrative Code 5122-36-05(B)-(C) - href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 - - title: Ohio Medicaid Eligibility Procedure Letter 175 - href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 - period: month + - title: Ohio Administrative Code 5122-36-05(B)-(C) + href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Medicaid Eligibility Procedure Letter 175 + href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml index 24e306648ea..d549c8c1d1f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml @@ -3,167 +3,132 @@ absolute_error_margin: 0.01 input: people: - first: + person1: age: 65 oh_rss_certified: true medicaid_enrolled: true - second: + person2: age: 65 oh_rss_certified: true medicaid_enrolled: true marital_units: couple: - members: - - first - - second + members: [person1, person2] tax_units: unit: - members: - - first - - second + members: [person1, person2] households: household: - members: - - first - - second + members: [person1, person2] state_code: OH output: - oh_rss: - - 1054.5 - - 1054.5 + oh_rss: [1_054.5, 1_054.5] + - name: Case 2, one spouse in approved care. period: 2026-01 absolute_error_margin: 0.01 input: people: - first: + person1: age: 65 oh_rss_certified: true medicaid_enrolled: true - second: + person2: age: 65 marital_units: couple: - members: - - first - - second + members: [person1, person2] tax_units: unit: - members: - - first - - second + members: [person1, person2] households: household: - members: - - first - - second + members: [person1, person2] state_code: OH output: - oh_rss: - - 1054.5 - - 0 + oh_rss: [1_054.5, 0] + - name: Case 3, ineligible spouse income is not deemed. period: 2026-01 absolute_error_margin: 0.01 input: people: - first: + person1: age: 65 oh_rss_certified: true medicaid_enrolled: true - second: + person2: age: 40 - social_security_retirement: 60000 + social_security_retirement: 60_000 marital_units: couple: - members: - - first - - second + members: [person1, person2] tax_units: unit: - members: - - first - - second + members: [person1, person2] households: household: - members: - - first - - second + members: [person1, person2] state_code: OH output: - oh_rss: - - 1800 - - 0 + oh_rss: [1_800, 0] + - name: Case 4, combined resource boundary. period: 2026-01 absolute_error_margin: 0.01 input: people: - first: + person1: age: 65 oh_rss_certified: true medicaid_enrolled: true - ssi_countable_resources: 2000 - second: + ssi_countable_resources: 2_000 + person2: age: 65 oh_rss_certified: true medicaid_enrolled: true - ssi_countable_resources: 1000 + ssi_countable_resources: 1_000 marital_units: couple: - members: - - first - - second + members: [person1, person2] tax_units: unit: - members: - - first - - second + members: [person1, person2] households: household: - members: - - first - - second + members: [person1, person2] state_code: OH output: - oh_rss: - - 1054.5 - - 1054.5 + oh_rss: [1_054.5, 1_054.5] + - name: Case 5, combined excess resources. period: 2026-01 absolute_error_margin: 0.01 input: people: - first: + person1: age: 65 oh_rss_certified: true medicaid_enrolled: true - ssi_countable_resources: 2000 - second: + ssi_countable_resources: 2_000 + person2: age: 65 oh_rss_certified: true medicaid_enrolled: true - ssi_countable_resources: 1001 + ssi_countable_resources: 1_001 marital_units: couple: - members: - - first - - second + members: [person1, person2] tax_units: unit: - members: - - first - - second + members: [person1, person2] households: household: - members: - - first - - second + members: [person1, person2] state_code: OH output: - oh_rss: - - 0 - - 0 + oh_rss: [0, 0] + - name: Case 6, annual benefits and SNAP income. period: 2026 absolute_error_margin: 0.01 @@ -173,10 +138,11 @@ oh_rss_certified: true medicaid_enrolled: true output: - oh_rss: 9672 - household_state_benefits: 9672 - snap_unearned_income: 21600 - hud_unearned_income: 21600 + oh_rss: 9_672 + household_state_benefits: 9_672 + snap_unearned_income: 21_600 + hud_unearned_income: 21_600 + - name: Case 7, partial month daily amount rounded upward. period: 2026-04 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml index d41e3064420..5f7aa3489b1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml @@ -8,6 +8,7 @@ medicaid_enrolled: true output: oh_rss: 806 + - name: Case 2, non SSI Social Security. period: 2026-01 absolute_error_margin: 0.01 @@ -16,9 +17,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 18000 + social_security_retirement: 18_000 output: oh_rss: 320 + - name: Case 3, income exactly at threshold. period: 2026-01 absolute_error_margin: 0.01 @@ -27,9 +29,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 21840 + social_security_retirement: 21_840 output: oh_rss: 0 + - name: Case 4, income just below threshold. period: 2026-01 absolute_error_margin: 0.01 @@ -38,9 +41,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 21828 + social_security_retirement: 21_828 output: oh_rss: 1 + - name: Case 5, income above threshold. period: 2026-01 absolute_error_margin: 0.01 @@ -49,9 +53,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 24000 + social_security_retirement: 24_000 output: oh_rss: 0 + - name: Case 6, earned income exclusion. period: 2026-01 absolute_error_margin: 0.01 @@ -60,9 +65,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - employment_income: 2220 + employment_income: 2_220 output: oh_rss: 806 + - name: Case 7, no care certification. period: 2026-01 absolute_error_margin: 0.01 @@ -73,6 +79,7 @@ medicaid_enrolled: true output: oh_rss: 0 + - name: Case 8, no Medicaid. period: 2026-01 absolute_error_margin: 0.01 @@ -83,6 +90,7 @@ medicaid_enrolled: false output: oh_rss: 0 + - name: Case 9, age seventeen. period: 2026-01 absolute_error_margin: 0.01 @@ -94,6 +102,7 @@ meets_ssi_disability_criteria: true output: oh_rss: 0 + - name: Case 10, age eighteen disabled. period: 2026-01 absolute_error_margin: 0.01 @@ -105,6 +114,7 @@ meets_ssi_disability_criteria: true output: oh_rss: 806 + - name: Case 11, not aged blind or disabled. period: 2026-01 absolute_error_margin: 0.01 @@ -115,6 +125,7 @@ medicaid_enrolled: true output: oh_rss: 0 + - name: Case 12, resource boundary. period: 2026-01 absolute_error_margin: 0.01 @@ -123,9 +134,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - ssi_countable_resources: 2000 + ssi_countable_resources: 2_000 output: oh_rss: 806 + - name: Case 13, excess resources. period: 2026-01 absolute_error_margin: 0.01 @@ -134,9 +146,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - ssi_countable_resources: 2001 + ssi_countable_resources: 2_001 output: oh_rss: 0 + - name: Case 14, out of state. period: 2026-01 absolute_error_margin: 0.01 @@ -147,6 +160,7 @@ medicaid_enrolled: true output: oh_rss: 0 + - name: Case 15, partial first month sixteen days. period: 2026-01 absolute_error_margin: 0.01 @@ -158,6 +172,7 @@ oh_rss_days_of_placement: 16 output: oh_rss: 416 + - name: Case 16, zero days of placement. period: 2026-01 absolute_error_margin: 0.01 From 518400c621992cc86ed1625f0d0c66cb16c4eda2 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 13 Oct 2026 02:34:09 -0400 Subject: [PATCH 3/7] Fix issues from review: Ohio RSS registries, rounding and tests - Registry placement and comments, SNAP reference title, shorter programs.yaml note - Rule-level OAC references; document the one-spouse resource-standard reading - Guard the daily-rate ceiling against float error on exact-cent amounts - State-neutral wording in the shared Medicaid helper documentation (same as UT) - Add interest/dividend, couple pooling and exact-cent partial-month cases; realistic Case 3 Co-Authored-By: Claude Opus 5.5 --- .../household/household_state_benefits.yaml | 12 ++- .../gov/states/oh/dbh/rss/allowable_fee.yaml | 4 +- .../gov/states/oh/dbh/rss/minimum_age.yaml | 2 +- .../oh/dbh/rss/personal_needs_allowance.yaml | 4 +- .../usda/snap/income/sources/unearned.yaml | 4 +- policyengine_us/programs.yaml | 9 +-- .../gov/states/oh/dbh/rss/integration.yaml | 75 ++++++++++++++++++- .../gov/states/oh/dbh/rss/oh_rss.yaml | 60 +++++++++++++++ ...icaid_enrolled_for_ssi_state_supplement.py | 2 +- .../variables/gov/states/oh/dbh/rss/oh_rss.py | 13 +++- .../gov/states/oh/dbh/rss/oh_rss_certified.py | 5 +- .../oh/dbh/rss/oh_rss_countable_income.py | 1 + .../gov/states/oh/dbh/rss/oh_rss_eligible.py | 5 +- .../income/spm_unit/spm_unit_benefits.py | 2 +- 14 files changed, 175 insertions(+), 23 deletions(-) diff --git a/policyengine_us/parameters/gov/household/household_state_benefits.yaml b/policyengine_us/parameters/gov/household/household_state_benefits.yaml index f389e5c2e9b..8f36ac9505f 100644 --- a/policyengine_us/parameters/gov/household/household_state_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_state_benefits.yaml @@ -67,6 +67,8 @@ values: - sc_child_care_subsidies # New Mexico benefits - nm_ssi_state_supplement + # Ohio benefits + - oh_rss # South Carolina benefits - sc_ssi_state_supplement # Texas benefits @@ -78,7 +80,6 @@ values: # DC benefits - dc_ossp # Kansas benefits - - oh_rss - ks_sspp # Hawaii benefits - hi_oss @@ -160,6 +161,8 @@ values: - ok_child_care_subsidies # New Mexico benefits - nm_ssi_state_supplement + # Ohio benefits + - oh_rss # South Carolina benefits - sc_ssi_state_supplement # Texas benefits @@ -177,7 +180,6 @@ values: # DC benefits - dc_ossp # Kansas benefits - - oh_rss - ks_sspp # Hawaii benefits - hi_oss @@ -263,6 +265,8 @@ values: - ok_child_care_subsidies # New Mexico benefits - nm_ssi_state_supplement + # Ohio benefits + - oh_rss # South Carolina benefits - sc_ssi_state_supplement # Texas benefits @@ -281,7 +285,6 @@ values: - dc_ossp - dc_child_care_subsidies # Kansas benefits - - oh_rss - ks_sspp # Hawaii benefits - hi_oss @@ -367,6 +370,8 @@ values: - ok_child_care_subsidies # New Mexico benefits - nm_ssi_state_supplement + # Ohio benefits + - oh_rss # South Carolina benefits - sc_ssi_state_supplement # Texas benefits @@ -385,7 +390,6 @@ values: - dc_ossp - dc_child_care_subsidies # Kansas benefits - - oh_rss - ks_sspp # Hawaii benefits - hi_oss diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml index d77ab4a2e03..1fdaaf7c648 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml @@ -7,7 +7,7 @@ metadata: period: month label: Ohio RSS allowable facility fee reference: - - title: Ohio Administrative Code 5122-36-05(B)-(C) - href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Administrative Code 5122-36-05(B) + href: https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-05 - title: Ohio Medicaid Eligibility Procedure Letter 175 href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml index 901a86bf647..7f6e67fb378 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml @@ -7,4 +7,4 @@ metadata: label: Ohio RSS minimum age reference: - title: Ohio Administrative Code 5122-36-02(A)(1) - href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + href: https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02 diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml index 401b4fc5a78..f68ba55c140 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml @@ -7,7 +7,7 @@ metadata: period: month label: Ohio RSS personal needs allowance reference: - - title: Ohio Administrative Code 5122-36-05(B)-(C) - href: https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36 + - title: Ohio Administrative Code 5122-36-05(C) + href: https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-05 - title: Ohio Medicaid Eligibility Procedure Letter 175 href: https://dam.assets.ohio.gov/image/upload/medicaid.ohio.gov/About%20Us/PoliciesGuidelines/MEPL/MEPL-175.pdf#page=1 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml index 49bd2c22fd4..eb2a975d296 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml @@ -36,7 +36,6 @@ values: - hi_oss - id_aabd - in_ssp - - oh_rss - ks_sspp - ky_ssp - la_oss @@ -48,6 +47,7 @@ values: - mo_ssp - ne_aabd - nm_ssi_state_supplement + - oh_rss - sc_ssi_state_supplement - tx_ssi_state_supplement - wa_ssp @@ -96,7 +96,7 @@ values: metadata: reference: - - title: 7 CFR 273.9(b)(2) and (c)(1), (5) + - title: 7 CFR 273.9(b)(2) and (c)(1) href: https://www.law.cornell.edu/cfr/text/7/273.9 - title: POMS SI 01401.001 General Information about State Supplementation href: https://secure.ssa.gov/poms.nsf/lnx/0501401001 diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 432675fab7a..74bf51e08f1 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1097,11 +1097,10 @@ programs: variable: oh_rss parameter_prefix: gov.states.oh.dbh.rss verified_years: '2026' - notes: Models approved full-month placements and first-month proration. Nonfinancial - care certification and Medicaid enrollment must be supplied when not determined by - the model. Specialized work-expense and PASS exclusions absent from the federal SSI - model require a countable-income override; no ineligible-spouse or parent income is - deemed. + notes: Models 2026 payments for approved placements, including first-month proration. + Care certification is an input, Medicaid enrollment must be supplied when the model + does not determine it, and PASS and specialized work-expense exclusions are not + modeled. - id: social_security name: Social Security diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml index d549c8c1d1f..1b85832acd8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml @@ -59,7 +59,8 @@ medicaid_enrolled: true person2: age: 40 - social_security_retirement: 60_000 + # BLS OEWS May 2025, Ohio all-occupations median annual wage. + employment_income: 49_380 marital_units: couple: members: [person1, person2] @@ -71,6 +72,16 @@ members: [person1, person2] state_code: OH output: + # SSI deems the spouse's wages: 49,380 / 12 = 4,115; + # (4,115 - 20 - 65) / 2 = 2,015. + ssi_income_deemed_from_ineligible_spouse: [2_015, 0] + # Couple rate under deeming: 1,491 - 2,015 < 0. + ssi: [0, 0] + oh_rss_eligible: [true, false] + # RSS counts only person1's own income (0) plus SSI (0). Person2 is not + # an RSS candidate; person2's own wages compute to (4,115 - 85) / 2 = 2,015. + oh_rss_countable_income: [0, 2_015] + # 1,600 fee + 200 allowance - 0 = 1,800. oh_rss: [1_800, 0] - name: Case 4, combined resource boundary. @@ -157,3 +168,65 @@ oh_rss_days_of_placement: 15 output: oh_rss: 403.05 + +- name: Case 8, couple income pooled with one general exclusion. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + oh_rss_certified: true + medicaid_enrolled: true + social_security_retirement: 12_000 + person2: + age: 70 + oh_rss_certified: true + medicaid_enrolled: true + social_security_retirement: 12_000 + marital_units: + couple: + members: [person1, person2] + tax_units: + unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OH + output: + oh_rss_eligible: [true, true] + # SSI couple computation: (1,000 + 1,000 - 20) / 2 = 990 each. + ssi_countable_income: [990, 990] + # 1,491 / 2 = 745.50 - 990 < 0. + ssi: [0, 0] + # RSS pools the couple's income: (2,000 - 20) / 2 = 990 each (not + # 1,000 - 20 = 980 with a separate exclusion per spouse); SSI adds 0. + oh_rss_countable_income: [990, 990] + # 1,600 fee + 200 allowance - 990 = 810 each. + oh_rss: [810, 810] + +- name: Case 9, partial month with a whole-cent daily amount. + period: 2026-04 + absolute_error_margin: 0.01 + input: + state_code: + 2026: OH + age: + 2026: 65 + oh_rss_certified: true + medicaid_enrolled: + 2026: true + # $1,578.50 a month, chosen so the daily amount is a whole cent. + social_security_retirement: + 2026: 18_942 + oh_rss_days_of_placement: 15 + output: + oh_rss_eligible: true + # 18,942 / 12 = 1,578.50; 1,578.50 - 20 = 1,558.50; 994 - 1,558.50 < 0. + ssi: 0 + # 1,558.50 + SSI 0. + oh_rss_countable_income: 1_558.5 + # 1,800 - 1,558.50 = 241.50; 241.50 / 30 days = 8.05 exactly (no + # float round-up to 8.06); 8.05 x 15 days = 120.75. + oh_rss: 120.75 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml index 5f7aa3489b1..89623ae2607 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml @@ -184,3 +184,63 @@ oh_rss_days_of_placement: 0 output: oh_rss: 0 + +- name: Case 17, interest and dividends excluded from RSS income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 55 + oh_rss_certified: true + medicaid_enrolled: true + meets_ssi_disability_criteria: true + # SSA Monthly Statistical Snapshot, August 2026: average disabled-worker + # benefit $1,635.90; $1,636 x 12 = 19,632. + social_security_disability: 19_632 + # $1,200 in savings at 4% and $400 in a dividend fund at 3%. + bank_account_assets: 1_200 + taxable_interest_income: 48 + stock_assets: 400 + qualified_dividend_income: 12 + output: + oh_rss_eligible: true + # SSI counts interest and dividends: 1,636 + 48 / 12 + 12 / 12 = 1,641. + ssi_unearned_income: 1_641 + # 1,641 - 20 = 1,621. + ssi_countable_income: 1_621 + # 994 - 1,621 < 0. + ssi: 0 + # 5160:1-3-03.2(D)(2)(v): 1,641 - 4 - 1 = 1,636; 1,636 - 20 = 1,616; SSI 0. + oh_rss_countable_income: 1_616 + # Resources 1,200 + 400 = 1,600 <= 2,000; 1,800 - 1,616 = 184. + oh_rss: 184 + +- name: Case 18, disabled working student earnings excluded. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 20 + oh_rss_certified: true + medicaid_enrolled: true + meets_ssi_disability_criteria: true + is_full_time_college_student: true + # Ohio minimum wage $11.00 (US DOL state minimum wage table) x 15 hours + # x 52 weeks = 8,580. + employment_income: 8_580 + # SSA Monthly Statistical Snapshot, August 2026: average benefit for + # children of deceased workers $1,181.07; $1,181 x 12 = 14,172. + social_security_survivors: 14_172 + output: + oh_rss_eligible: true + # 8,580 / 12 = 715 < 2,410 monthly limit; 8,580 a year < 9,730 cap. + ssi_blind_or_disabled_working_student_exclusion: 715 + # Earned 715 - 715 = 0; unearned 1,181 - 20 = 1,161. + ssi_countable_income: 1_161 + # 994 - 1,161 < 0. + ssi: 0 + # Same exclusion: 0 earned + (1,181 - 20) unearned + SSI 0 = 1,161. + oh_rss_countable_income: 1_161 + # 1,800 - 1,161 = 639. Without the exclusion, (715 - 65) / 2 = 325 more + # would count and pay 314. + oh_rss: 639 diff --git a/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py b/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py index 4721d768617..e5e6cf14aef 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py @@ -14,7 +14,7 @@ class medicaid_enrolled_for_ssi_state_supplement(Variable): ) documentation = ( "Medicaid enrollment condition for SSI/aged/blind/disabled state " - "supplement candidates only (Indiana, Kansas and Ohio). " + "supplement candidates only (state supplements that require Medicaid enrollment). " "SSI recipients and aged, blind, or disabled RCAP candidates do not " "need the SNAP/TANF community-engagement exclusion. Other consumers " "must use medicaid_enrolled, which retains that exclusion." diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py index cd78ac9bc14..8e5d38b0960 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -8,13 +8,20 @@ class oh_rss(Variable): definition_period = MONTH unit = USD defined_for = "oh_rss_eligible" - reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" + reference = ( + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-05.1", + ) def formula(person, period, parameters): p = parameters(period).gov.states.oh.dbh.rss limits = parameters(period).gov.ssa.ssi.eligibility.resources.limit eligible = person("oh_rss_eligible", period) couple = person.marital_unit.sum(eligible) == 2 + # When only one spouse qualifies, 5160:1-5-01(E)(3) applies the + # individual standard: own resources against $2,000. Source conflict: + # 5160:1-3-05.1(B)(9)(b) and 5160:1-3-05.20(E) would combine the + # couple's resources against $3,000. resources = person("ssi_countable_resources", period.this_year) resources = where(couple, person.marital_unit.sum(resources), resources) limit = where(couple, limits.couple, limits.individual) @@ -27,6 +34,8 @@ def formula(person, period, parameters): days = clip(person("oh_rss_days_of_placement", period), 0, period.days) # First-month placement is prorated after rounding the daily payment # upward to the cent. Full months retain the unprorated benefit. - daily_payment = np.ceil(payment.astype(np.float64) / period.days * 100) / 100 + daily_payment = ( + np.ceil(payment.astype(np.float64) / period.days * 100 - 1e-8) / 100 + ) partial = np.ceil(daily_payment * days * 100 - 1e-8) / 100 return (resources <= limit) * where(days == period.days, payment, partial) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py index d615ea08aad..5824f328258 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py @@ -7,4 +7,7 @@ class oh_rss_certified(Variable): label = "Ohio RSS certified care placement" definition_period = MONTH documentation = "Ohio has approved the current RSS placement and protective level of care, including the facility, supervision, waiver, PACE, hospice and provider-relationship conditions in rule 5122-36-02. Includes a state-approved temporary institutional continuation under 5160:1-5-01(H)." - reference = "https://codes.ohio.gov/ohio-administrative-code/chapter-5122-36" + reference = ( + "https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02", + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", + ) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py index eec123ac193..51969043d7f 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py @@ -10,6 +10,7 @@ class oh_rss_countable_income(Variable): label = "Ohio RSS countable income" definition_period = MONTH unit = USD + defined_for = StateCode.OH reference = ( "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-03.2", diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py index a5c3eaafb2e..ba1b6c34fc7 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py @@ -7,7 +7,10 @@ class oh_rss_eligible(Variable): label = "Meets Ohio RSS categorical and placement requirements" definition_period = MONTH defined_for = StateCode.OH - reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" + reference = ( + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", + "https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02", + ) def formula(person, period, parameters): p = parameters(period).gov.states.oh.dbh.rss diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py index 24bfff223e2..00d6ce1f05c 100644 --- a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py @@ -22,7 +22,7 @@ def formula(spm_unit, period, parameters): "ky_ssp", # Kentucky benefits "de_ssp", # Delaware benefits "fl_oss", - "oh_rss", + "oh_rss", # Ohio benefits "ks_sspp", # Kansas benefits "hi_oss", "la_oss", # Louisiana benefits From 20887655714e8912f3db9715f27e3cc33f788453 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 13 Oct 2026 15:05:14 -0400 Subject: [PATCH 4/7] Leave Ohio RSS out of HUD income to avoid a Medicaid cycle Counting oh_rss as HUD income made Medicaid enrollment, rent-related tax deductions and HUD income circular, so the shared SSP Medicaid helper had to run a full-population branch in every year and the microsimulation job ran out of time and memory. RSS recipients live in licensed residential care, so RSS stays out of HUD income, and the helper and its core test return to main's version (only the docstring wording differs). Co-Authored-By: Claude Opus 5.5 --- .../hud/annual_income/sources/unearned.yaml | 1 - ...si_state_supplement_medicaid_dependency.py | 23 +++---------- .../gov/states/oh/dbh/rss/integration.yaml | 3 +- ...icaid_enrolled_for_ssi_state_supplement.py | 33 ++++++++----------- .../variables/gov/states/oh/dbh/rss/oh_rss.py | 3 ++ 5 files changed, 23 insertions(+), 40 deletions(-) diff --git a/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml b/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml index a49d0e66a4e..9224ea96b39 100644 --- a/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/hud/annual_income/sources/unearned.yaml @@ -19,7 +19,6 @@ values: - strike_benefits - social_security - ssi - - oh_rss - general_assistance - unemployment_compensation - child_support_received diff --git a/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py b/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py index 0ac611e783c..a64c80a17a7 100644 --- a/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py +++ b/policyengine_us/tests/core/test_ssi_state_supplement_medicaid_dependency.py @@ -23,8 +23,6 @@ def situation(year, state="IN"): "is_snap_workfare_participant": {year: True}, }, } - if state == "OH": - people["recipient"]["oh_rss_certified"] = {year: True} return { "people": people, **{ @@ -37,14 +35,11 @@ def situation(year, state="IN"): } -@pytest.mark.parametrize("year", [2026, 2027, 2030, 2035]) -@pytest.mark.parametrize( - "state,benefit", - [("IN", "in_ssp"), ("KS", "ks_sspp"), ("OH", "oh_rss")], -) +@pytest.mark.parametrize("year", [2027, 2030, 2035]) +@pytest.mark.parametrize("state,benefit", [("IN", "in_ssp"), ("KS", "ks_sspp")]) def test_ssp_snap_medicaid_calculation_order(year, state, benefit): """YAML cannot express repeated calculations in different orders.""" - variables = (benefit, "snap", "medicaid_enrolled", "hud_unearned_income") + variables = (benefit, "snap", "medicaid_enrolled") results = [] for order in (variables, variables[::-1]): simulation = Simulation(situation=situation(year, state)) @@ -59,10 +54,7 @@ def test_ssp_snap_medicaid_calculation_order(year, state, benefit): assert results[0]["snap"][1] > 0 # Kansas does not cover this childless adult through Medicaid expansion; # SNAP pass-through does not replace Medicaid's category requirement. - assert results[0]["medicaid_enrolled"].tolist() == [ - True, - state in {"IN", "OH", "UT"}, - ] + assert results[0]["medicaid_enrolled"].tolist() == [True, state == "IN"] @pytest.mark.parametrize( @@ -70,12 +62,7 @@ def test_ssp_snap_medicaid_calculation_order(year, state, benefit): ["medicaid_enrolled", "is_medicaid_eligible", "takes_up_medicaid_if_eligible"], ) @pytest.mark.parametrize( - "state,eligibility", - [ - ("IN", "in_ssp_sapn_eligible"), - ("KS", "ks_sspp_eligible"), - ("OH", "oh_rss_eligible"), - ], + "state,eligibility", [("IN", "in_ssp_sapn_eligible"), ("KS", "ks_sspp_eligible")] ) def test_ssp_preserves_medicaid_inputs_set_after_initialization( variable, state, eligibility diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml index 1b85832acd8..cdb2104fdef 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml @@ -152,7 +152,8 @@ oh_rss: 9_672 household_state_benefits: 9_672 snap_unearned_income: 21_600 - hud_unearned_income: 21_600 + # HUD income excludes RSS (see oh_rss): SSI 994 * 12 = 11_928. + hud_unearned_income: 11_928 - name: Case 7, partial month daily amount rounded upward. period: 2026-04 diff --git a/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py b/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py index e5e6cf14aef..aadcfc81799 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/hhs/medicaid/medicaid_enrolled_for_ssi_state_supplement.py @@ -22,37 +22,30 @@ class medicaid_enrolled_for_ssi_state_supplement(Variable): def formula(person, period, parameters): p = parameters(period).gov.hhs.medicaid.eligibility.work_requirements + if not p.applies: + return person("medicaid_enrolled", period) + simulation = person.simulation enrolled = simulation.get_array("medicaid_enrolled", period) if enrolled is not None: return enrolled - # SNAP counts supplements as income, so its work-requirement - # exemption cannot determine Medicaid for SSI/ABD supplement candidates. - # HUD also counts supplements. Medicaid's optional tax-filing check can - # reach HUD through rent-related tax deductions. While determining - # enrollment, withhold the supplements that require that enrollment; - # evaluate their actual grants only in the original simulation. SSI/ABD - # Medicaid eligibility does not depend on those housing tax deductions. - # Preserve all other Medicaid conditions, take-up and supplied inputs. + # SNAP counts these state supplements as income, so asking for receipt + # status here would recurse. SSP candidates qualify through SSI or + # age/blindness/disability independently of SNAP/TANF. Keep all other + # Medicaid conditions, take-up and supplied inputs in a private branch. branch_name = f"{simulation.branch_name}_ssi_state_supplement_medicaid_{period}" - overrides = { - "medicaid_enrolled_for_ssi_state_supplement": ( - period, - np.zeros(person.count, dtype=bool), - ) - } - if p.applies: - overrides["medicaid_community_engagement_pass_through_eligible"] = ( - period.first_month, - np.zeros(person.count, dtype=bool), - ) try: branch = get_override_branch( simulation, branch_name, period, - overrides, + { + "medicaid_community_engagement_pass_through_eligible": ( + period.first_month, + np.zeros(person.count, dtype=bool), + ) + }, ) return branch.calculate("medicaid_enrolled", period) finally: diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py index 8e5d38b0960..f7ab0f620ed 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -12,6 +12,9 @@ class oh_rss(Variable): "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-05.1", ) + # NOTE: Not in the HUD income list. RSS is paid to the person (5122-36-04(A)(7)), + # but recipients live in licensed residential care (5122-36-02(A)(5)), and HUD + # income would make the Medicaid condition circular via rent tax deductions. def formula(person, period, parameters): p = parameters(period).gov.states.oh.dbh.rss From 38f7c5379984badbcc8c207497f59a36d659f85a Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 20 Oct 2026 03:36:17 -0400 Subject: [PATCH 5/7] Address review findings on Ohio RSS Replace the days-of-placement formula input with a zero-default partial-month input, cite ORC 5119.41 and document that the rules pay non-recipients, add NOTEs for the couple standard, resources, federal SSI gaps and the Medicaid gate, update parameter metadata and the registry entry, and pin the readings with new cases. Co-Authored-By: Claude Opus 5.5 --- .../household/household_state_benefits.yaml | 4 +- .../gov/states/oh/dbh/rss/allowable_fee.yaml | 2 +- .../gov/states/oh/dbh/rss/minimum_age.yaml | 3 +- .../oh/dbh/rss/personal_needs_allowance.yaml | 2 +- policyengine_us/programs.yaml | 10 +- .../gov/states/oh/dbh/rss/integration.yaml | 200 ++++++++++++++++-- .../gov/states/oh/dbh/rss/oh_rss.yaml | 92 ++++++-- .../variables/gov/states/oh/dbh/rss/oh_rss.py | 31 ++- .../gov/states/oh/dbh/rss/oh_rss_certified.py | 5 +- .../oh/dbh/rss/oh_rss_countable_income.py | 23 +- .../oh/dbh/rss/oh_rss_days_of_placement.py | 12 -- .../gov/states/oh/dbh/rss/oh_rss_eligible.py | 5 + .../oh/dbh/rss/oh_rss_partial_month_days.py | 16 ++ 13 files changed, 339 insertions(+), 66 deletions(-) delete mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py create mode 100644 policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_partial_month_days.py diff --git a/policyengine_us/parameters/gov/household/household_state_benefits.yaml b/policyengine_us/parameters/gov/household/household_state_benefits.yaml index b8ad3d3070d..50da556caf3 100644 --- a/policyengine_us/parameters/gov/household/household_state_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_state_benefits.yaml @@ -70,10 +70,10 @@ values: - nj_ssp # New Mexico benefits - nm_ssi_state_supplement - # Ohio benefits - - oh_rss # North Carolina benefits - nc_sa + # Ohio benefits + - oh_rss # Pennsylvania benefits - pa_ssp # South Carolina benefits diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml index 1fdaaf7c648..1265bb544cb 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/allowable_fee.yaml @@ -1,4 +1,4 @@ -description: Ohio provides this monthly allowable facility fee under its Residential State Supplement program. +description: Ohio sets this amount as the monthly allowable fee under the Residential State Supplement program. values: 2023-07-01: 1_600 diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml index 7f6e67fb378..bc1f091c04c 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml @@ -1,9 +1,10 @@ -description: Ohio limits its Residential State Supplement to people this age or older. +description: Ohio sets this age as the minimum age under the Residential State Supplement program. values: 2022-09-03: 18 metadata: unit: year + period: year label: Ohio RSS minimum age reference: - title: Ohio Administrative Code 5122-36-02(A)(1) diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml index f68ba55c140..2f6cdf821f0 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/personal_needs_allowance.yaml @@ -1,4 +1,4 @@ -description: Ohio provides this monthly personal needs allowance under its Residential State Supplement program. +description: Ohio provides this amount as the monthly personal needs allowance under the Residential State Supplement program. values: 2023-07-01: 200 diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 422685815a6..98d4a58e300 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1154,11 +1154,11 @@ programs: full_name: Ohio Residential State Supplement variable: oh_rss parameter_prefix: gov.states.oh.dbh.rss - verified_years: '2026' - notes: Models 2026 payments for approved placements, including first-month proration. - Care certification is an input, Medicaid enrollment must be supplied when the model - does not determine it, and PASS and specialized work-expense exclusions are not - modeled. + verified_years: "2026" + notes: >- + Models 2026 payments for approved residential placements, including + first-month proration. Care certification is an input, and PASS and + specialized work-expense exclusions are not modeled. - id: social_security name: Social Security diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml index cdb2104fdef..8484ecdee58 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml @@ -22,9 +22,15 @@ members: [person1, person2] state_code: OH output: + oh_rss_eligible: [true, true] + # SSI couple rate 1,491 / 2 = 745.50 each. + ssi: [745.5, 745.5] + # RSS pools the couple's SSI: 1,491 / 2 = 745.50 each. + oh_rss_countable_income: [745.5, 745.5] + # (2 x 1,800 - 1,491) / 2 = 1,054.50 each. oh_rss: [1_054.5, 1_054.5] -- name: Case 2, one spouse in approved care. +- name: Case 2, one spouse in approved care, both in the same RSS home. period: 2026-01 absolute_error_margin: 0.01 input: @@ -35,6 +41,7 @@ medicaid_enrolled: true person2: age: 65 + medicaid_enrolled: true marital_units: couple: members: [person1, person2] @@ -46,6 +53,12 @@ members: [person1, person2] state_code: OH output: + oh_rss_eligible: [true, false] + # Both spouses are aged, so SSI pays the couple rate: 1,491 / 2 = 745.50. + ssi: [745.5, 745.5] + # 5160:1-5-01(E)(3) uses the individual standard: own SSI 745.50 only. + oh_rss_countable_income: [745.5, 745.5] + # 1,800 - 745.50 = 1,054.50; person2 has no level of care. oh_rss: [1_054.5, 0] - name: Case 3, ineligible spouse income is not deemed. @@ -59,6 +72,7 @@ medicaid_enrolled: true person2: age: 40 + medicaid_enrolled: false # BLS OEWS May 2025, Ohio all-occupations median annual wage. employment_income: 49_380 marital_units: @@ -110,6 +124,9 @@ members: [person1, person2] state_code: OH output: + # 2,000 + 1,000 = 3,000 <= 3,000 couple limit; SSI 1,491 / 2 = 745.50. + ssi: [745.5, 745.5] + # (2 x 1,800 - 1,491) / 2 = 1,054.50 each. oh_rss: [1_054.5, 1_054.5] - name: Case 5, combined excess resources. @@ -138,6 +155,8 @@ members: [person1, person2] state_code: OH output: + # 2,000 + 1,001 = 3,001 > 3,000 couple limit for both SSI and RSS. + ssi: [0, 0] oh_rss: [0, 0] - name: Case 6, annual benefits and SNAP income. @@ -148,10 +167,17 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true + takes_up_snap_if_eligible: false output: + # 994 x 12 = 11,928. + ssi: 11_928 + # (1,800 - 994) x 12 = 9,672. oh_rss: 9_672 household_state_benefits: 9_672 + # SSI 11,928 + RSS 9,672 = 21,600. snap_unearned_income: 21_600 + # SSI 11,928 + RSS 9,672 = 21,600, with SNAP take-up off. + spm_unit_benefits: 21_600 # HUD income excludes RSS (see oh_rss): SSI 994 * 12 = 11_928. hud_unearned_income: 11_928 @@ -166,8 +192,10 @@ oh_rss_certified: true medicaid_enrolled: 2026: true - oh_rss_days_of_placement: 15 + oh_rss_partial_month_days: 15 output: + # 1,800 - SSI 994 = 806; 806 / 30 days = 26.8667, rounded up to 26.87; + # 26.87 x 15 days = 403.05. oh_rss: 403.05 - name: Case 8, couple income pooled with one general exclusion. @@ -179,12 +207,16 @@ age: 70 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 12_000 + # SSA Monthly Statistical Snapshot, August 2026, Table 2: average + # retired-worker benefit $2,087.52; 2,087.52 x 12 = 25,050.24. + social_security_retirement: 25_050.24 person2: age: 70 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 12_000 + # Same table: average benefit for a retired worker's spouse $986.70; + # 986.70 x 12 = 11,840.40. + social_security_dependents: 11_840.4 marital_units: couple: members: [person1, person2] @@ -197,15 +229,15 @@ state_code: OH output: oh_rss_eligible: [true, true] - # SSI couple computation: (1,000 + 1,000 - 20) / 2 = 990 each. - ssi_countable_income: [990, 990] - # 1,491 / 2 = 745.50 - 990 < 0. + # SSI couple computation: (2,087.52 + 986.70 - 20) / 2 = 1,527.11 each. + ssi_countable_income: [1_527.11, 1_527.11] + # 1,491 / 2 = 745.50 - 1,527.11 < 0. ssi: [0, 0] - # RSS pools the couple's income: (2,000 - 20) / 2 = 990 each (not - # 1,000 - 20 = 980 with a separate exclusion per spouse); SSI adds 0. - oh_rss_countable_income: [990, 990] - # 1,600 fee + 200 allowance - 990 = 810 each. - oh_rss: [810, 810] + # RSS pools the couple's income: (3,074.22 - 20) / 2 = 1,527.11 each (not + # 2,067.52 and 966.70 with a separate exclusion per spouse); SSI adds 0. + oh_rss_countable_income: [1_527.11, 1_527.11] + # 1,600 fee + 200 allowance - 1,527.11 = 272.89 each. + oh_rss: [272.89, 272.89] - name: Case 9, partial month with a whole-cent daily amount. period: 2026-04 @@ -218,16 +250,144 @@ oh_rss_certified: true medicaid_enrolled: 2026: true - # $1,578.50 a month, chosen so the daily amount is a whole cent. - social_security_retirement: - 2026: 18_942 - oh_rss_days_of_placement: 15 + # Set directly so the daily amount is a whole cent. + oh_rss_countable_income: 1_558.5 + oh_rss_partial_month_days: 15 output: oh_rss_eligible: true - # 18,942 / 12 = 1,578.50; 1,578.50 - 20 = 1,558.50; 994 - 1,558.50 < 0. - ssi: 0 - # 1,558.50 + SSI 0. - oh_rss_countable_income: 1_558.5 # 1,800 - 1,558.50 = 241.50; 241.50 / 30 days = 8.05 exactly (no # float round-up to 8.06); 8.05 x 15 days = 120.75. oh_rss: 120.75 + +- name: Case 10, partial-month days entered for one spouse leave the other spouse's month full. + period: 2026-04 + absolute_error_margin: 0.01 + input: + people: + person1: + age: + 2026: 65 + oh_rss_certified: true + medicaid_enrolled: + 2026: true + # Moved into the RSS home on April 16: April 16 through 30. + oh_rss_partial_month_days: 15 + person2: + age: + 2026: 65 + oh_rss_certified: true + medicaid_enrolled: + 2026: true + marital_units: + couple: + members: [person1, person2] + tax_units: + unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: + 2026: OH + output: + oh_rss_partial_month_days: [15, 0] + oh_rss_eligible: [true, true] + # SSI couple rate 1,491 / 2 = 745.50 each. + ssi: [745.5, 745.5] + oh_rss_countable_income: [745.5, 745.5] + # (2 x 1,800 - 1,491) / 2 = 1,054.50; person1: 1,054.50 / 30 days = + # 35.15 x 15 days = 527.25; person2 was in the home all month. + oh_rss: [527.25, 1_054.5] + +- name: Case 11, partial-month days entered for one month leave the next month full. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + medicaid_enrolled: true + oh_rss_certified: + 2026-03: false + 2026-04: true + 2026-05: true + oh_rss_partial_month_days: + 2026-04: 15 + output: + oh_rss_partial_month_days: + 2026-04: 15 + 2026-05: 0 + # March: no placement yet. April: 806 / 30 days = 26.8667, rounded up to + # 26.87; 26.87 x 15 days = 403.05. May is a full month: 1,800 - 994 = 806. + oh_rss: + 2026-03: 0 + 2026-04: 403.05 + 2026-05: 806 + +- name: Case 12, spouses in different RSS homes entered as separate marital units. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + person2: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + marital_units: + m1: + members: [person1] + m2: + members: [person2] + tax_units: + unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OH + output: + # Each spouse is budgeted as an individual: SSI 994 each. + ssi: [994, 994] + oh_rss_eligible: [true, true] + oh_rss_countable_income: [994, 994] + # 1,800 - 994 = 806 each. + oh_rss: [806, 806] + +# NOTE: 5160:1-5-01(E)(3) applies the individual standard when only one spouse +# has a level of care, so the model tests person1's own resources against +# $2,000. 5160:1-3-05.1(B)(9)(b) and 5160:1-3-05.20(E)(1)-(2) would instead +# combine the couple's 3,100 against $3,000 and pay 0. +- name: Case 13, one eligible spouse tested on own resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + ssi_countable_resources: 1_500 + person2: + age: 65 + medicaid_enrolled: true + ssi_countable_resources: 1_600 + marital_units: + couple: + members: [person1, person2] + tax_units: + unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OH + output: + oh_rss_eligible: [true, false] + # SSI combines the couple's resources: 1,500 + 1,600 = 3,100 > 3,000. + ssi: [0, 0] + oh_rss_countable_income: [0, 0] + # Person1's own 1,500 <= 2,000; 1,600 + 200 - 0 = 1,800. + oh_rss: [1_800, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml index 89623ae2607..d00bd48643a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml @@ -7,6 +7,10 @@ oh_rss_certified: true medicaid_enrolled: true output: + # No other income: SSI pays the 994 federal benefit rate. + ssi: 994 + oh_rss_countable_income: 994 + # 1,600 fee + 200 allowance - SSI 994 = 806. oh_rss: 806 - name: Case 2, non SSI Social Security. @@ -17,11 +21,17 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 18_000 + # SSA Monthly Statistical Snapshot, August 2026, Table 2: average + # disabled-worker benefit $1,635.90; 1,635.90 x 12 = 19,630.80. + social_security_disability: 19_630.8 output: - oh_rss: 320 + # 1,635.90 - 20 = 1,615.90 > 994, so no SSI. + ssi: 0 + oh_rss_countable_income: 1_615.9 + # 1,800 - 1,615.90 = 184.10. + oh_rss: 184.1 -- name: Case 3, income exactly at threshold. +- name: Case 3, countable income exactly at the income standard. period: 2026-01 absolute_error_margin: 0.01 input: @@ -29,11 +39,13 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 21_840 + oh_rss_countable_income: 1_800 output: + oh_rss_eligible: true + # 5160:1-5-01(E)(4)(b): income equal to the 1,800 standard is ineligible. oh_rss: 0 -- name: Case 4, income just below threshold. +- name: Case 4, countable income one dollar below the income standard. period: 2026-01 absolute_error_margin: 0.01 input: @@ -41,8 +53,9 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 21_828 + oh_rss_countable_income: 1_799 output: + # 1,800 - 1,799 = 1. oh_rss: 1 - name: Case 5, income above threshold. @@ -53,8 +66,14 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - social_security_retirement: 24_000 + # SSA Monthly Statistical Snapshot, August 2026, Table 2: average + # retired-worker benefit $2,087.52; 2,087.52 x 12 = 25,050.24. + social_security_retirement: 25_050.24 output: + # 2,087.52 - 20 = 2,067.52 > 994, so no SSI. + ssi: 0 + oh_rss_countable_income: 2_067.52 + # 2,067.52 > 1,800 standard. oh_rss: 0 - name: Case 6, earned income exclusion. @@ -65,8 +84,17 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - employment_income: 2_220 + # Ohio minimum wage $11.00 (US DOL state minimum wage table) x 5 hours + # x 52 weeks = 2,860. + employment_income: 2_860 output: + # 2,860 / 12 = 238.33; (238.33 - 20 - 65) / 2 = 76.67. + ssi_countable_income: 76.67 + # 994 - 76.67 = 917.33. + ssi: 917.33 + # Same exclusions: 76.67 + SSI 917.33 = 994. + oh_rss_countable_income: 994 + # 1,800 - 994 = 806. oh_rss: 806 - name: Case 7, no care certification. @@ -101,6 +129,7 @@ medicaid_enrolled: true meets_ssi_disability_criteria: true output: + # Below the minimum age of 18 (5122-36-02(A)(1)). oh_rss: 0 - name: Case 10, age eighteen disabled. @@ -113,6 +142,7 @@ medicaid_enrolled: true meets_ssi_disability_criteria: true output: + # 1,600 + 200 - SSI 994 = 806. oh_rss: 806 - name: Case 11, not aged blind or disabled. @@ -136,6 +166,7 @@ medicaid_enrolled: true ssi_countable_resources: 2_000 output: + # 2,000 <= 2,000 individual limit; 1,800 - SSI 994 = 806. oh_rss: 806 - name: Case 13, excess resources. @@ -148,6 +179,7 @@ medicaid_enrolled: true ssi_countable_resources: 2_001 output: + # 2,001 > 2,000 individual limit. oh_rss: 0 - name: Case 14, out of state. @@ -169,11 +201,12 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - oh_rss_days_of_placement: 16 + oh_rss_partial_month_days: 16 output: + # 806 / 31 days = 26.00 a day; 26.00 x 16 days = 416. oh_rss: 416 -- name: Case 16, zero days of placement. +- name: Case 16, zero partial-month days pays the full month. period: 2026-01 absolute_error_margin: 0.01 input: @@ -181,9 +214,10 @@ age: 65 oh_rss_certified: true medicaid_enrolled: true - oh_rss_days_of_placement: 0 + oh_rss_partial_month_days: 0 output: - oh_rss: 0 + # 0 means a full month: 1,600 + 200 - SSI 994 = 806. + oh_rss: 806 - name: Case 17, interest and dividends excluded from RSS income. period: 2026-01 @@ -204,7 +238,8 @@ qualified_dividend_income: 12 output: oh_rss_eligible: true - # SSI counts interest and dividends: 1,636 + 48 / 12 + 12 / 12 = 1,641. + # PolicyEngine's federal SSI counts interest and dividends (#10079): + # 1,636 + 48 / 12 + 12 / 12 = 1,641. ssi_unearned_income: 1_641 # 1,641 - 20 = 1,621. ssi_countable_income: 1_621 @@ -244,3 +279,34 @@ # 1,800 - 1,161 = 639. Without the exclusion, (715 - 65) / 2 = 325 more # would count and pay 314. oh_rss: 639 + +- name: Case 19, Medicaid enrollment computed by the model. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + output: + # An aged SSI recipient qualifies for Ohio Medicaid with no input. + medicaid_enrolled: true + oh_rss_eligible: true + ssi: 994 + # 1,600 + 200 - SSI 994 = 806. + oh_rss: 806 + +# 5122-36-05 sets the fee and allowance with no cost-of-living adjustment, so +# the 2027 SSI increase lowers RSS by the same amount. +- name: Case 20, fixed RSS income standard with the 2027 SSI rate. + period: 2027-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + output: + # 2027 SSI federal benefit rate. + ssi: 1_029 + # 1,600 + 200 - SSI 1,029 = 771. + oh_rss: 771 diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py index f7ab0f620ed..4085b86be14 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -9,22 +9,35 @@ class oh_rss(Variable): unit = USD defined_for = "oh_rss_eligible" reference = ( + "https://codes.ohio.gov/ohio-revised-code/section-5119.41", "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-05.1", ) # NOTE: Not in the HUD income list. RSS is paid to the person (5122-36-04(A)(7)), # but recipients live in licensed residential care (5122-36-02(A)(5)), and HUD # income would make the Medicaid condition circular via rent tax deductions. + # NOTE: ORC 5119.41(A) describes RSS as supplementing "the amounts received by + # aged, blind, or disabled adults as supplemental security income payments ... + # or as social security benefits or social security disability insurance + # benefits". Under 5119.41(B), eligibility is set by the rules, and neither + # 5160:1-5-01(C) nor 5122-36-02(A) requires receipt of SSI or Social Security. + # Ohio's Medicaid conditions of eligibility (5160:1-2-10(B)) also do not + # require applying for SSI. The model follows the rules, so it pays eligible + # people who receive neither benefit and does not require SSI take-up. def formula(person, period, parameters): p = parameters(period).gov.states.oh.dbh.rss + # Ohio prints the same $2,000 individual and $3,000 couple resource + # limits (5160:1-3-05.1(B)(9)), so the SSI parameters are used and an + # SSI reform carries over to RSS. limits = parameters(period).gov.ssa.ssi.eligibility.resources.limit eligible = person("oh_rss_eligible", period) couple = person.marital_unit.sum(eligible) == 2 - # When only one spouse qualifies, 5160:1-5-01(E)(3) applies the - # individual standard: own resources against $2,000. Source conflict: - # 5160:1-3-05.1(B)(9)(b) and 5160:1-3-05.20(E) would combine the - # couple's resources against $3,000. + # NOTE: When only one spouse qualifies, 5160:1-5-01(E)(3) applies the + # individual standard, so the model tests own resources against + # $2,000. Source conflict: 5160:1-3-05.1(B)(9)(b) and + # 5160:1-3-05.20(E)(1)-(2) would combine the couple's resources against + # $3,000. The model follows the RSS rule. resources = person("ssi_countable_resources", period.this_year) resources = where(couple, person.marital_unit.sum(resources), resources) limit = where(couple, limits.couple, limits.individual) @@ -34,11 +47,13 @@ def formula(person, period, parameters): - person("oh_rss_countable_income", period), 0, ) - days = clip(person("oh_rss_days_of_placement", period), 0, period.days) - # First-month placement is prorated after rounding the daily payment - # upward to the cent. Full months retain the unprorated benefit. + days = person("oh_rss_partial_month_days", period) + prorate = (days > 0) & (days < period.days) + # A first month that starts after the first day is prorated: the daily + # amount and the prorated total are each rounded up to the next whole + # cent (5160:1-5-01(G)(5)-(6)). daily_payment = ( np.ceil(payment.astype(np.float64) / period.days * 100 - 1e-8) / 100 ) partial = np.ceil(daily_payment * days * 100 - 1e-8) / 100 - return (resources <= limit) * where(days == period.days, payment, partial) + return (resources <= limit) * where(prorate, partial, payment) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py index 5824f328258..df6a592a89d 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_certified.py @@ -6,8 +6,11 @@ class oh_rss_certified(Variable): entity = Person label = "Ohio RSS certified care placement" definition_period = MONTH - documentation = "Ohio has approved the current RSS placement and protective level of care, including the facility, supervision, waiver, PACE, hospice and provider-relationship conditions in rule 5122-36-02. Includes a state-approved temporary institutional continuation under 5160:1-5-01(H)." + documentation = "Ohio has approved the current RSS placement and protective level of care, including the facility, supervision, waiver, PACE, hospice and provider-relationship conditions in rule 5122-36-02. Includes a state-approved temporary institutional continuation under 5160:1-5-01(H). Spouses are budgeted together only when they live in the same RSS living arrangement. Enter spouses who live apart as separate marital units from the month after they stop living together; marital units cannot change between months of one calculation." reference = ( "https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02", "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", + "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-05.20", ) + # NOTE: No microdata input sets this, so oh_rss is 0 in microsimulation and + # reaches household calculations only. diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py index 51969043d7f..c9c25a2ef6f 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py @@ -34,10 +34,19 @@ def formula(person, period, parameters): ) earned = where(couple, person.marital_unit.sum(earned), earned) unearned = where(couple, person.marital_unit.sum(unearned), unearned) + # NOTE: PolicyEngine's federal SSI still counts interest and dividends + # (#10079), so for an SSI recipient with that income the SSI counted + # here is understated and RSS is overstated by the same amount. + # NOTE: SSI's joint-claim test reads only aged, blind or disabled + # status, so a spouse who declines SSI is still budgeted jointly + # (#10138). federal_ssi = person("ssi", period) federal_ssi = where(couple, person.marital_unit.sum(federal_ssi), federal_ssi) - # Apply exclusions once per couple. SSI is needs-based income and does - # not receive the general $20 disregard (D)(2)(l). + # Ohio applies the same $20 general, $65 earned and one-half earned + # income exclusions (5160:1-3-03.2(D)(2)(l), (E)(5), (E)(7)), so the SSI + # exclusion parameters are used and an SSI reform carries over to RSS. + # They apply once per couple. SSI is needs-based income and does not + # receive the general $20 disregard (D)(2)(l). countable = ( _apply_ssi_exclusions( earned * MONTHS_IN_YEAR, unearned * MONTHS_IN_YEAR, parameters, period @@ -45,4 +54,14 @@ def formula(person, period, parameters): / MONTHS_IN_YEAR + federal_ssi ) + # NOTE: 5160:1-5-01(E)(2) determines a couple's eligibility + # "collectively, utilizing the appropriate couple income standard as + # defined in rule 5160:1-3-03.5", and 5160:1-3-03.5(B)(2) defines that + # standard as "the current supplemental security income (SSI) benefit + # rate for a couple". Read literally, a couple whose only income is SSI + # would be at that standard and ineligible under (E)(4)(b). Ohio + # publishes only the RSS income standard of 5160:1-5-01(B)(2), so the + # model compares pooled countable income with twice the RSS income + # standard, which pays each spouse the RSS income standard less half + # the pooled income. return where(couple, countable / 2, countable) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py deleted file mode 100644 index faf4570903e..00000000000 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_days_of_placement.py +++ /dev/null @@ -1,12 +0,0 @@ -from policyengine_us.model_api import * - - -class oh_rss_days_of_placement(Variable): - value_type = int - entity = Person - label = "Days of Ohio RSS placement in the month" - definition_period = MONTH - reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" - - def formula(person, period, parameters): - return period.days diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py index ba1b6c34fc7..cb812625271 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py @@ -7,10 +7,15 @@ class oh_rss_eligible(Variable): label = "Meets Ohio RSS categorical and placement requirements" definition_period = MONTH defined_for = StateCode.OH + documentation = "Medicaid enrollment comes from medicaid_enrolled. If you enter it, enter it for every person in the simulation; an entry for one person sets everyone else to false." reference = ( "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", "https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02", ) + # NOTE: PolicyEngine's Ohio aged, blind or disabled Medicaid income limit is + # the SSI benefit rate (5160:1-3-03.5(B)(3)), and it has no group for people + # who receive only an optional state supplement. A person whose income is + # above that rate therefore needs medicaid_enrolled entered. def formula(person, period, parameters): p = parameters(period).gov.states.oh.dbh.rss diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_partial_month_days.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_partial_month_days.py new file mode 100644 index 00000000000..8e6c6bbeeee --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_partial_month_days.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class oh_rss_partial_month_days(Variable): + value_type = int + entity = Person + label = "Ohio RSS days of placement in a partial first month" + definition_period = MONTH + defined_for = StateCode.OH + default_value = 0 + documentation = "For a first month in which the person moves into the RSS living arrangement after the first day, the days from the move-in day through the last day of the month. Leave at 0 for a full month." + reference = "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01" + + def formula(person, period, parameters): + # A formula stops a one-month input from carrying into later months. + return 0 From 801e0c06936d70fa682fa9860f3c9d48ddafa928 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 20 Oct 2026 16:04:38 -0400 Subject: [PATCH 6/7] Cover Ohio's state-supplement-only Medicaid group and fix proration rounding - Meet the RSS Medicaid condition through the 42 CFR 435.232 group (SPA 15-015) for candidates who meet SSI's other criteria but whose income is too high. - Allow a thousandth of a cent before rounding the daily amount up. - Pin the one-spouse income standard and the computed-Medicaid couple. - Start the minimum age on 2017-07-15 and note microsimulation in programs.yaml. Co-Authored-By: Claude Opus 5.5 --- .../gov/states/oh/dbh/rss/minimum_age.yaml | 4 +- policyengine_us/programs.yaml | 5 +- .../gov/states/oh/dbh/rss/integration.yaml | 65 +++++++++++++++++++ .../gov/states/oh/dbh/rss/oh_rss.yaml | 37 +++++++++++ .../variables/gov/states/oh/dbh/rss/oh_rss.py | 10 ++- .../oh/dbh/rss/oh_rss_countable_income.py | 5 +- .../gov/states/oh/dbh/rss/oh_rss_eligible.py | 26 ++++++-- 7 files changed, 139 insertions(+), 13 deletions(-) diff --git a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml index bc1f091c04c..4eac9f45c7c 100644 --- a/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml +++ b/policyengine_us/parameters/gov/states/oh/dbh/rss/minimum_age.yaml @@ -1,6 +1,6 @@ description: Ohio sets this age as the minimum age under the Residential State Supplement program. values: - 2022-09-03: 18 + 2017-07-15: 18 metadata: unit: year @@ -9,3 +9,5 @@ metadata: reference: - title: Ohio Administrative Code 5122-36-02(A)(1) href: https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02 + - title: Ohio Administrative Code 5122-36-02(A), effective July 15, 2017 + href: https://codes.ohio.gov/assets/laws/administrative-code/authenticated/5122/0/36/5122-36-02_20170715.pdf#page=1 diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 98d4a58e300..677cd2c3a59 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1157,8 +1157,9 @@ programs: verified_years: "2026" notes: >- Models 2026 payments for approved residential placements, including - first-month proration. Care certification is an input, and PASS and - specialized work-expense exclusions are not modeled. + first-month proration. Care certification is an input, so + microsimulation pays no RSS; PASS and specialized work-expense + exclusions are not modeled. - id: social_security name: Social Security diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml index 8484ecdee58..5dd0297e5b4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/integration.yaml @@ -391,3 +391,68 @@ oh_rss_countable_income: [0, 0] # Person1's own 1,500 <= 2,000; 1,600 + 200 - 0 = 1,800. oh_rss: [1_800, 0] + +- name: Case 14, one eligible spouse with Medicaid computed by the model and combined resources above the couple limit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 65 + oh_rss_certified: true + ssi_countable_resources: 1_500 + person2: + age: 65 + ssi_countable_resources: 1_600 + marital_units: + couple: + members: [person1, person2] + tax_units: + unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OH + output: + # Case 13 without the Medicaid input. Medicaid and SSI combine the + # couple's resources (5160:1-3-05.20(E)(1)): 3,100 > 3,000, so person1 + # meets neither Medicaid path. + ssi: [0, 0] + oh_rss_eligible: [false, false] + oh_rss: [0, 0] + +- name: Case 15, one eligible spouse compared with the RSS income standard. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + oh_rss_certified: true + meets_ssi_disability_criteria: true + # $1,200 a month in Social Security disability benefits. + social_security_disability: 14_400 + person2: + age: 58 + marital_units: + couple: + members: [person1, person2] + tax_units: + unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OH + output: + # Person2 is not aged, blind or disabled and has no income to deem: + # 1,200 - 20 = 1,180 > 994, so no SSI. + ssi: [0, 0] + # Person1 meets SSI's other criteria, so the Medicaid condition is met + # without an input. + oh_rss_eligible: [true, false] + oh_rss_countable_income: [1_180, 0] + # 5160:1-5-01(E)(3) points to the $994 individual standard; the model uses + # the RSS standard: 1,800 - 1,180 = 620. + oh_rss: [620, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml index d00bd48643a..1cd83c0deeb 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/dbh/rss/oh_rss.yaml @@ -310,3 +310,40 @@ ssi: 1_029 # 1,600 + 200 - SSI 1,029 = 771. oh_rss: 771 + +# 42 CFR 435.232 (SPA 15-015, Attachment 2.2-A): Ohio covers people who receive +# only a state supplement and would be eligible for SSI except for income. +- name: Case 21, non SSI Social Security with Medicaid computed by the model. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + # Case 2's average disabled-worker benefit, with no Medicaid input. + social_security_disability: 19_630.8 + output: + # 1,635.90 - 20 = 1,615.90 > 994, so no SSI; SSI's other criteria are met. + ssi: 0 + oh_rss_eligible: true + # 1,800 - 1,615.90 = 184.10. + oh_rss: 184.1 + +- name: Case 22, partial first month with a whole-cent daily amount from monthly income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: OH + age: 65 + oh_rss_certified: true + medicaid_enrolled: true + # 17,249.52 / 12 = 1,437.46 a month, a payment that divides into whole + # cents a day. + social_security_retirement: 17_249.52 + oh_rss_partial_month_days: 16 + output: + # 1,437.46 - 20 = 1,417.46 > 994, so no SSI. + oh_rss_countable_income: 1_417.46 + # 1,800 - 1,417.46 = 382.54; 382.54 / 31 = 12.34 a day exactly; + # 12.34 x 16 days = 197.44. + oh_rss: 197.44 diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py index 4085b86be14..d06398c536c 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -37,7 +37,10 @@ def formula(person, period, parameters): # individual standard, so the model tests own resources against # $2,000. Source conflict: 5160:1-3-05.1(B)(9)(b) and # 5160:1-3-05.20(E)(1)-(2) would combine the couple's resources against - # $3,000. The model follows the RSS rule. + # $3,000. The model follows the RSS rule. The reading decides the + # result only when medicaid_enrolled is entered: computed Medicaid and + # the SSI resource test both combine the couple's resources, so a + # couple above $3,000 fails the Medicaid condition. resources = person("ssi_countable_resources", period.this_year) resources = where(couple, person.marital_unit.sum(resources), resources) limit = where(couple, limits.couple, limits.individual) @@ -51,9 +54,10 @@ def formula(person, period, parameters): prorate = (days > 0) & (days < period.days) # A first month that starts after the first day is prorated: the daily # amount and the prorated total are each rounded up to the next whole - # cent (5160:1-5-01(G)(5)-(6)). + # cent (5160:1-5-01(G)(5)-(6)). Countable income is stored in float32, + # so the daily amount allows a thousandth of a cent before rounding up. daily_payment = ( - np.ceil(payment.astype(np.float64) / period.days * 100 - 1e-8) / 100 + np.ceil(payment.astype(np.float64) / period.days * 100 - 1e-3) / 100 ) partial = np.ceil(daily_payment * days * 100 - 1e-8) / 100 return (resources <= limit) * where(prorate, partial, payment) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py index c9c25a2ef6f..8988542e5be 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py @@ -63,5 +63,8 @@ def formula(person, period, parameters): # publishes only the RSS income standard of 5160:1-5-01(B)(2), so the # model compares pooled countable income with twice the RSS income # standard, which pays each spouse the RSS income standard less half - # the pooled income. + # the pooled income. When only one spouse qualifies, (E)(3) uses "the + # appropriate individual income standard as defined in rule + # 5160:1-3-03.5", the SSI individual rate. The model compares that + # spouse's own countable income with the RSS income standard instead. return where(couple, countable / 2, countable) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py index cb812625271..c8e8d5d658f 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_eligible.py @@ -7,21 +7,35 @@ class oh_rss_eligible(Variable): label = "Meets Ohio RSS categorical and placement requirements" definition_period = MONTH defined_for = StateCode.OH - documentation = "Medicaid enrollment comes from medicaid_enrolled. If you enter it, enter it for every person in the simulation; an entry for one person sets everyone else to false." + documentation = "Medicaid enrollment comes from medicaid_enrolled. A candidate who meets SSI's criteria other than income but whose income is too high for SSI meets the Medicaid requirement through Ohio's group for people who receive only a state supplement, even when medicaid_enrolled is entered as false. If you enter medicaid_enrolled, enter it for every person in the simulation; an entry for one person sets everyone else to false." reference = ( "https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01", "https://codes.ohio.gov/ohio-administrative-code/rule-5122-36-02", + # PDF pages 17-18 + "https://www.medicaid.gov/State-resource-center/Medicaid-State-Plan-Amendments/Downloads/OH/OH-15-015.pdf#page=17", ) - # NOTE: PolicyEngine's Ohio aged, blind or disabled Medicaid income limit is - # the SSI benefit rate (5160:1-3-03.5(B)(3)), and it has no group for people - # who receive only an optional state supplement. A person whose income is - # above that rate therefore needs medicaid_enrolled entered. def formula(person, period, parameters): p = parameters(period).gov.states.oh.dbh.rss + # 5160:1-5-01(C)(1)(a) requires eligibility for medical assistance. + # PolicyEngine's Ohio aged, blind or disabled Medicaid income limit is + # the SSI benefit rate (5160:1-3-03.5(B)(3)). Ohio's state plan also + # covers, under 42 CFR 435.232, people who receive only a state + # supplement and "would be eligible for SSI except for the level of + # their income", including aged, blind and disabled individuals in + # group living arrangements (SPA 15-015, Attachment 2.2-A, pages + # 15-16). RSS pays the difference between countable income and its + # income standard in cash, so a candidate who meets SSI's other + # criteria but whose income is too high for SSI meets the condition. + medicaid = person( + "medicaid_enrolled_for_ssi_state_supplement", period.this_year + ) + state_supplement_only = person("is_ssi_eligible", period) & ( + person("uncapped_ssi", period) <= 0 + ) return ( person("oh_rss_certified", period) & (person("age", period.this_year) >= p.minimum_age) & person("is_ssi_aged_blind_disabled", period.this_year) - & person("medicaid_enrolled_for_ssi_state_supplement", period.this_year) + & (medicaid | state_supplement_only) ) From 30b5c8dde533cc8f5c6de9a1fc814baf8d019fc3 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 20 Oct 2026 18:24:02 -0400 Subject: [PATCH 7/7] Reword a source NOTE that involves no statute The new source-conflict guard (#10287) registers statute conflicts only; this NOTE compares a regulation with another regulation or a handbook. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py index d06398c536c..ff1cde2f8e8 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -35,9 +35,9 @@ def formula(person, period, parameters): couple = person.marital_unit.sum(eligible) == 2 # NOTE: When only one spouse qualifies, 5160:1-5-01(E)(3) applies the # individual standard, so the model tests own resources against - # $2,000. Source conflict: 5160:1-3-05.1(B)(9)(b) and - # 5160:1-3-05.20(E)(1)-(2) would combine the couple's resources against - # $3,000. The model follows the RSS rule. The reading decides the + # $2,000. Ohio's Medicaid resource rules, 5160:1-3-05.1(B)(9)(b) and + # 5160:1-3-05.20(E)(1)-(2), would instead combine the couple's resources + # against $3,000. The model follows the RSS rule. The reading decides the # result only when medicaid_enrolled is entered: computed Medicaid and # the SSI resource test both combine the couple's resources, so a # couple above $3,000 fails the Medicaid condition.