Summary
In HouseholdPropertyFunction.compute_demand, the renting mask at macromodel/agents/households/func/property.py:261 tests the tenure status field against 0:
ind_renting = np.array(household_residence_tenure_status == 0)
The field never holds 0. The mask is therefore empty on every call, and renting households are excluded from the buy-versus-rent decision entirely. The owning mask on line 266 has a narrower version of the same problem.
The encoding
Tenure Status of the Main Residence carries HFCS HB0300 codes, imported at macro_data/readers/population_data/hfcs_reader.py:94. The codebase's own treatment of those codes is consistent everywhere except the two lines above — 3 is renting, {1, 2, 4} is owning, and -1 marks social housing:
| Site |
Test |
macro_data/processing/synthetic_population/hfcs_household_tools.py:113-114 |
== 3 renting, .isin([1, 2, 4]) owning |
macro_data/processing/synthetic_matching/matching_households_with_houses.py:319, 403, 466 |
== 3 renting, .isin([1, 2, 4]) owning |
macromodel/agents/households/households.py:932, 947, 1331 |
== 3 renting, np.isin(..., [1, 2, 4]) owning |
macromodel/country/country.py:1365 |
== 3 to select renters for the rental income tax base |
The only writes to the field in the entire tree are:
| Site |
Value |
macro_data/processing/synthetic_matching/matching_households_with_houses.py:370 |
-1 (renter moved into social housing) |
macromodel/markets/housing_market/housing_market.py:468 |
3 (household rents a property) |
macromodel/markets/housing_market/housing_market.py:494 |
1 (household buys a property) |
So the reachable value set is {-1, 1, 2, 3, 4}. 0 is not in it, and no dataset is needed to establish that — it follows from the assignment sites alone.
The imported values agree. Across the 2010, 2014, 2017 and 2021 HFCS waves — all ten implicates of each — HB0300 never takes the value 0:
| Wave |
HB0300 values present |
Zeros |
NaN |
| 2010 |
1, 2, 3, 4, 6 |
0 |
0 |
| 2014 |
1, 2, 3, 4 |
0 |
5 |
| 2017 |
1, 2, 3, 4 |
0 |
10 |
| 2021 |
1, 2, 3, 4 |
0 |
0 |
Two incidental observations from that sweep, both independent of the defect above but relevant to how a fix should be written. The 2010 wave carries an additional code 6, which falls into neither == 3 nor isin([1, 2, 4]). A small number of NaN values appear in the 2014 and 2017 waves, and NaN fails every comparison. Households in either category are silently dropped from all three masks rather than raising, so the suggested fix would benefit from an explicit branch — or an assertion that the observed code set is covered — instead of leaving unmatched households to fall through unnoticed.
Effects
ind_renting feeds ind_dec at property.py:273, which gates the whole decision:
ind_dec = ind_in_social_housing | ind_renting_not_staying | ind_owning_not_staying
1. Renting households never participate in the housing market. They cannot buy, and they cannot move to another rental. Renting is an absorbing state: once a household holds code 3, nothing in the model can change its tenure again.
2. Owning households with codes 2 and 4 are likewise frozen. Line 266 tests == 1 rather than isin([1, 2, 4]), so those owners never enter ind_owning_not_staying, never list their main residence for sale via households.py:783, and never move.
3. Market participation decays monotonically over a run. Social-housing households (-1) are matched correctly and do participate. When one of them wins a rental, housing_market.py:468 writes code 3, moving it permanently into the frozen set. There is no path back to -1, so the participating population drains toward the code-1 owners alone as a simulation proceeds.
4. Both sides of the market are biased, not just one. max_rent_willing_to_pay is populated only for households in ind_dec and flows into rental clearing at macromodel/markets/housing_market/func/clearing.py:131, so rental demand omits every sitting renter. households_hoping_to_move is ind_owning_not_staying, so sale supply omits every code-2/4 owner.
5. probability_stay_in_rented_property has no effect. At property.py:264, inside the ind_renting_not_staying block spanning lines 262-265, it is applied to the empty renting mask. The parameter can be set to any value in [0, 1] without changing model output, which means it also cannot be calibrated.
Reproduction
Effects 1, 2, 4 and 5 are observed rather than inferred; effect 3 follows from the write sites listed above, since 3 and 1 are the only values written anywhere under macromodel/ at runtime. macromodel/agents/households/func/property.py imports only numpy, pandas and scipy, so compute_demand can be exercised directly with no dataset. Both stay-probabilities are set to 0.0 below, meaning every renter and every owner wants to move — so any household still excluded is excluded structurally rather than by chance, and the noise variance is set to 0.0 to make the result deterministic.
Feeding one household of each tenure code:
| Code |
Meaning |
Admitted to housing market |
After the suggested fix |
-1 |
social housing |
yes |
yes |
1 |
owning outright |
yes |
yes |
2 |
part owning |
no |
yes |
3 |
renting |
no |
yes |
4 |
free use |
no |
yes |
|
total |
2 / 5 |
5 / 5 |
|
flagged as hoping to move (sale supply) |
1 |
3 |
max_rent_willing_to_pay comes back as NaN for codes 2, 3 and 4, so sitting renters submit no rental bid at all.
On the parameter: sweeping probability_stay_in_rented_property across 0.0, 0.25, 0.5, 0.75, 1.0 leaves every output array bit-identical. Applying the suggested fix and repeating the sweep makes the same parameter change the result, which confirms the parameter is genuinely inert under the current encoding rather than merely unused in this configuration.
These participation counts are exact and deterministic. We have not gone further and quantified how far prices, rents or tenure shares move once the excluded households re-enter the market, which would need a controlled comparison with seed variation.
Suggested fix
ind_renting = np.array(household_residence_tenure_status == 3)
ind_owning = np.isin(household_residence_tenure_status, [1, 2, 4])
This matches the convention used at every other site listed above.
Worth noting that the fix is behaviour-changing rather than cosmetic: it admits a substantial group of households into the housing market for the first time, so house prices, rents and tenure composition will all move relative to any existing baseline. Anyone with calibrated housing parameters will likely need to revisit them, and probability_stay_in_rented_property becomes live.
These lines predate the HFCS-code migration
We looked into whether the exclusion might be deliberate, and the history suggests it is not. The tenure field originally used a binary encoding in which 0 meant renting and 1 meant owning. Under that scheme lines 261 and 266 were correct, and git blame dates them to 28bbc942 (2024-05-17), while the binary scheme was still in force.
Commit d11bc5f (2025-05-15) migrated the model to preserve the raw HFCS codes instead, and rewrote the comparisons at every other call site:
| File |
Before |
After |
macro_data/processing/synthetic_population/hfcs_household_tools.py |
== 0 / == 1 |
== 3 / .isin([1, 2, 4]) |
macromodel/agents/households/households.py |
== 0 / == 1 |
== 3 / np.isin(..., [1, 2, 4]) |
macromodel/country/country.py |
== 0 |
== 3 |
macro_data/processing/synthetic_matching/matching_households_with_houses.py |
== 1 |
.isin([1, 2, 4]) |
macromodel/agents/households/func/property.py |
== 0 / == 1 |
not modified |
That commit does not touch func/property.py. The two lines are the only remaining consumers of the retired binary encoding, which is why they now compare against a value the field can no longer hold.
The same commit introduced the comment at hfcs_household_tools.py:111-112 — "This maintains codes used in HFCS. 1, 2 and 4 are owning, part owning or free use. 3 is renting" — recording the new contract explicitly. The docstring above it at line 93-95 still describes the retired binary mapping, and may be worth updating alongside any fix.
Summary
In
HouseholdPropertyFunction.compute_demand, the renting mask atmacromodel/agents/households/func/property.py:261tests the tenure status field against0:The field never holds
0. The mask is therefore empty on every call, and renting households are excluded from the buy-versus-rent decision entirely. The owning mask on line 266 has a narrower version of the same problem.The encoding
Tenure Status of the Main Residencecarries HFCSHB0300codes, imported atmacro_data/readers/population_data/hfcs_reader.py:94. The codebase's own treatment of those codes is consistent everywhere except the two lines above —3is renting,{1, 2, 4}is owning, and-1marks social housing:macro_data/processing/synthetic_population/hfcs_household_tools.py:113-114== 3renting,.isin([1, 2, 4])owningmacro_data/processing/synthetic_matching/matching_households_with_houses.py:319, 403, 466== 3renting,.isin([1, 2, 4])owningmacromodel/agents/households/households.py:932, 947, 1331== 3renting,np.isin(..., [1, 2, 4])owningmacromodel/country/country.py:1365== 3to select renters for the rental income tax baseThe only writes to the field in the entire tree are:
macro_data/processing/synthetic_matching/matching_households_with_houses.py:370-1(renter moved into social housing)macromodel/markets/housing_market/housing_market.py:4683(household rents a property)macromodel/markets/housing_market/housing_market.py:4941(household buys a property)So the reachable value set is
{-1, 1, 2, 3, 4}.0is not in it, and no dataset is needed to establish that — it follows from the assignment sites alone.The imported values agree. Across the 2010, 2014, 2017 and 2021 HFCS waves — all ten implicates of each —
HB0300never takes the value0:HB0300values presentNaNTwo incidental observations from that sweep, both independent of the defect above but relevant to how a fix should be written. The 2010 wave carries an additional code
6, which falls into neither== 3norisin([1, 2, 4]). A small number ofNaNvalues appear in the 2014 and 2017 waves, andNaNfails every comparison. Households in either category are silently dropped from all three masks rather than raising, so the suggested fix would benefit from an explicit branch — or an assertion that the observed code set is covered — instead of leaving unmatched households to fall through unnoticed.Effects
ind_rentingfeedsind_decatproperty.py:273, which gates the whole decision:1. Renting households never participate in the housing market. They cannot buy, and they cannot move to another rental. Renting is an absorbing state: once a household holds code
3, nothing in the model can change its tenure again.2. Owning households with codes
2and4are likewise frozen. Line 266 tests== 1rather thanisin([1, 2, 4]), so those owners never enterind_owning_not_staying, never list their main residence for sale viahouseholds.py:783, and never move.3. Market participation decays monotonically over a run. Social-housing households (
-1) are matched correctly and do participate. When one of them wins a rental,housing_market.py:468writes code3, moving it permanently into the frozen set. There is no path back to-1, so the participating population drains toward the code-1owners alone as a simulation proceeds.4. Both sides of the market are biased, not just one.
max_rent_willing_to_payis populated only for households inind_decand flows into rental clearing atmacromodel/markets/housing_market/func/clearing.py:131, so rental demand omits every sitting renter.households_hoping_to_moveisind_owning_not_staying, so sale supply omits every code-2/4owner.5.
probability_stay_in_rented_propertyhas no effect. Atproperty.py:264, inside theind_renting_not_stayingblock spanning lines 262-265, it is applied to the empty renting mask. The parameter can be set to any value in[0, 1]without changing model output, which means it also cannot be calibrated.Reproduction
Effects 1, 2, 4 and 5 are observed rather than inferred; effect 3 follows from the write sites listed above, since
3and1are the only values written anywhere undermacromodel/at runtime.macromodel/agents/households/func/property.pyimports onlynumpy,pandasandscipy, socompute_demandcan be exercised directly with no dataset. Both stay-probabilities are set to0.0below, meaning every renter and every owner wants to move — so any household still excluded is excluded structurally rather than by chance, and the noise variance is set to0.0to make the result deterministic.Feeding one household of each tenure code:
-11234max_rent_willing_to_paycomes back asNaNfor codes2,3and4, so sitting renters submit no rental bid at all.On the parameter: sweeping
probability_stay_in_rented_propertyacross0.0, 0.25, 0.5, 0.75, 1.0leaves every output array bit-identical. Applying the suggested fix and repeating the sweep makes the same parameter change the result, which confirms the parameter is genuinely inert under the current encoding rather than merely unused in this configuration.These participation counts are exact and deterministic. We have not gone further and quantified how far prices, rents or tenure shares move once the excluded households re-enter the market, which would need a controlled comparison with seed variation.
Suggested fix
This matches the convention used at every other site listed above.
Worth noting that the fix is behaviour-changing rather than cosmetic: it admits a substantial group of households into the housing market for the first time, so house prices, rents and tenure composition will all move relative to any existing baseline. Anyone with calibrated housing parameters will likely need to revisit them, and
probability_stay_in_rented_propertybecomes live.These lines predate the HFCS-code migration
We looked into whether the exclusion might be deliberate, and the history suggests it is not. The tenure field originally used a binary encoding in which
0meant renting and1meant owning. Under that scheme lines 261 and 266 were correct, andgit blamedates them to28bbc942(2024-05-17), while the binary scheme was still in force.Commit
d11bc5f(2025-05-15) migrated the model to preserve the raw HFCS codes instead, and rewrote the comparisons at every other call site:macro_data/processing/synthetic_population/hfcs_household_tools.py== 0/== 1== 3/.isin([1, 2, 4])macromodel/agents/households/households.py== 0/== 1== 3/np.isin(..., [1, 2, 4])macromodel/country/country.py== 0== 3macro_data/processing/synthetic_matching/matching_households_with_houses.py== 1.isin([1, 2, 4])macromodel/agents/households/func/property.py== 0/== 1That commit does not touch
func/property.py. The two lines are the only remaining consumers of the retired binary encoding, which is why they now compare against a value the field can no longer hold.The same commit introduced the comment at
hfcs_household_tools.py:111-112— "This maintains codes used in HFCS. 1, 2 and 4 are owning, part owning or free use. 3 is renting" — recording the new contract explicitly. The docstring above it at line 93-95 still describes the retired binary mapping, and may be worth updating alongside any fix.