Skip to content

[Animal] Streamline Animal Culling Inputs - #3241

Open
allisterakun wants to merge 12 commits into
devfrom
streamline_animal_culling_inputs
Open

allisterakun wants to merge 12 commits into
devfrom
streamline_animal_culling_inputs

Conversation

@allisterakun

@allisterakun allisterakun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Streamlines the animal culling and death input system by replacing the complex multi-stage culling pipeline (reason selection → day-in-lactation interpolation → future-date scheduling) with a simple daily probability roll for mortality and acute sale, evaluated every day rather than once per lactation.

Context

Issue(s) closed by this pull request: closes #2694.

What

  • Replaced determine_future_death_date() with will_die_tomorrow() — converts the parity-indexed annual parity_death_probability to a daily rate (÷365) and rolls once per day, returning a boolean.
  • Replaced determine_future_cull_date() with will_be_sold_tomorrow() — converts the parity-indexed annual parity_acute_sale_probability to a daily rate (÷365) and rolls once per day, returning a boolean. Renamed from "cull" to "acute sale" for clarity.
  • Added _assess_removal_risk() method on Animal — called daily for every cow (and once when a heifer first calves), independently rolls mortality and acute sale risk. Only schedules a removal if no pending event of that type already exists, preventing later rolls from overwriting earlier scheduled removals. Removal is scheduled for the current day (self.days_born).
  • Added _parity_index() helper — returns a 0-based index into by-parity arrays, capping parity 4+ at index 3.
  • Moved the _assess_removal_risk() call from inside daily_reproduction_update (where it ran only at calving) to daily_routines (where it runs every day for all cows).
  • Removed from AnimalConfig: death_day_probability, parity_cull_probability, cull_day_count, feet_leg_cull_probability, feet_leg_cull_day_probability, injury_cull_probability, injury_cull_day_probability, mastitis_cull_probability, mastitis_cull_day_probability, disease_cull_probability, disease_cull_day_probability, udder_cull_probability, udder_cull_day_probability, unknown_cull_probability, unknown_cull_day_probability. These entire reason-specific CDF and interpolation datasets are no longer needed.
  • Renamed parity_cull_probability to parity_acute_sale_probability in AnimalConfig, updated its docstring.
  • Removed LAMENESS_CULL, INJURY_CULL, MASTITIS_CULL, DISEASE_CULL, UDDER_CULL, UNKNOWN_CULL constants from animal_constants.py. Added ACUTE_SALE_CULL.
  • Removed cull_reason from HerdStatistics and its reporting in AnimalModuleReporter.
  • Updated default.json properties file to remove all removed culling parameters and rename the remaining ones.
  • Updated example_freestall_animal.json and example_open_lot_animal.json to use the new streamlined parameters.
  • Added animal_cross_validation.json with cross-validation rules for the new culling inputs.
  • Updated unit tests across test_animal.py, test_animal_config.py, test_animal_module_reporter.py, test_herd_statistics.py, pytest_fixtures.py, and test_herd_manager_herd_statistics.py.

Why

The previous culling system was overly complex: it evaluated removal risk only once per lactation (at calving), selected a specific cull reason via nested probability cascades, then interpolated a future removal date from reason-specific cumulative day-probability distributions. This required ~20 input parameters (reason probabilities, per-reason CDFs, day count arrays) that were difficult for users to configure. The new approach evaluates mortality and acute sale risk daily using just two parity-indexed annual probability arrays, which are converted to daily rates. This is simpler to understand, configure, and maintain, eliminates ~800 lines of code, and distributes removal risk over time rather than concentrating it at calving events.

How

Each day in daily_routines, _assess_removal_risk() is called for every cow. It checks whether a death or acute sale event is already pending (if so, that risk type is skipped). For each non-pending risk, it calls will_die_tomorrow() or will_be_sold_tomorrow(), which divides the annual parity-indexed probability by 365 to get a daily rate and compares a random draw against it. If the draw succeeds, the removal is scheduled for the current day. _parity_index() maps the cow's parity to a 0-based index (capping parity ≥4 at index 3). The specific cull reason selection and day-in-lactation interpolation are entirely removed.

Test plan

  • Updated unit tests for will_die_tomorrow, will_be_sold_tomorrow, _assess_removal_risk, and _parity_index.
  • Updated test_animal_config.py to reflect the removed parameters.
  • Updated test_animal_module_reporter.py and test_herd_statistics.py to remove cull_reason references.
  • Updated test fixtures in pytest_fixtures.py and test_herd_manager_herd_statistics.py.
  • 99% code coverage maintained. No mypy error count change (1133 on both branch and dev).

Input Changes

  • Deleted: death_day_probability, parity_cull_probability, cull_day_count, feet_leg_cull_probability, feet_leg_cull_day_probability, injury_cull_probability, injury_cull_day_probability, mastitis_cull_probability, mastitis_cull_day_probability, disease_cull_probability, disease_cull_day_probability, udder_cull_probability, udder_cull_day_probability, unknown_cull_probability, unknown_cull_day_probability from default.json and animal input JSON files.
  • Added: parity_acute_sale_probability (renamed from parity_cull_probability) in default.json and animal input JSON files.
  • Added: input/metadata/cross_validation/animal_cross_validation.json.

Output Changes

  • HerdStatistics.cull_reason: Removed.
  • AnimalModuleReporter.report_herd_statistics_data.cull_reason: Removed.

Filter

@github-actions

Copy link
Copy Markdown
Contributor

Current Coverage: 99%

Mypy errors on streamline_animal_culling_inputs branch: 1133
Mypy errors on dev branch: 1133
No difference in error counts

@github-actions

Copy link
Copy Markdown
Contributor

🚨 Please update the changelog. This PR cannot be merged until changelog_WIP.md is updated.
🚨 Unauthorized changes detected in protected files. Please remove these changes if they are not intended.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Current Coverage: 99%

Mypy errors on streamline_animal_culling_inputs branch: 1164
Mypy errors on dev branch: 1164
No difference in error counts

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚨 Please update the changelog. This PR cannot be merged until changelog_WIP.md is updated.
🚨 Unauthorized changes detected in protected files. Please remove these changes if they are not intended.

@allisterakun
allisterakun changed the base branch from dev to test September 8, 2026 03:09
@allisterakun
allisterakun changed the base branch from test to dev September 8, 2026 03:10
Comment on lines +2442 to +2450
if self.future_death_date == sys.maxsize:
if self.will_die_tomorrow():
self.future_death_date = self.days_born
self._future_death_reason = animal_constants.DEATH_CULL

if self.future_cull_date == sys.maxsize:
if self.will_be_sold_tomorrow():
self.future_cull_date = self.days_born
self.cull_reason = animal_constants.ACUTE_SALE_CULL

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gmg228 @jadamchick Please confirm this logic that if an animal is determined to be sold/dead, the we will mark her to be dead on the same day (at the EOD)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thank you.
A quick clarification because I can't quite tell myself - once a cow is selected for removal/death, she is not still automatically evaluate for acute sale the same day, right?

Second, could we add in the change in risk based on 'day' (essentially stage of lactation being 2 categories, fresh or not). Below is the calculation that Julie put together!

Update daily risk according to DIM category (DIM=1-49 vs. DIM>=50)
Two equations with two unknowns:
Average daily removal probability = fresh_daily_removal_prob x % fresh + other_daily_removal_prob x % other

Use an example calving interval of 400d
So % fresh = 50/400 = 0.125
% other = 1 - 0.125 = 0.875

0.0001 = fresh_daily_removal_prob x 0.125 + other_daily_removal_prob x 0.875

Fresh_daily_removal_prob = 2.55 x other_daily_removal_prob
Solve for the unknowns: rearrange + substitute
… [rearrange and substitute] …
Other_daily_removal_prob = avg_daily_removal_prob / (2.55 x % fresh + % other)
Example:
Other_daily_removal_prob = 0.00011 / (2.55*0.125 + 0.875)
= 9.215 x 10^-5
Fresh_daily_removal_prob = 2.55 x other_daily_removal_prob
= 2.55 x 9.215 x 10^-5
= 0.00023

Comment thread RUFAS/biophysical/animal/animal.py Outdated
"""Return the 0-based index into a by-parity array, capping parity 4+ at the last entry."""
return 3 if self.calves >= 4 else self.calves - 1

def will_die_tomorrow(self) -> bool:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help me choose a better function name plzzzz

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can kick around some ideas with @jadamchick

will_be_removed_due_to_death
is_selected_for_mortality (or death)
will_exit_due_to_mortality (death)
will_have_mortality_event
will_have_death_event

DISEASE_CULL = "culled for disease"
UDDER_CULL = "culled for udder"
UNKNOWN_CULL = "culled for unknown"
ACUTE_SALE_CULL = "culled for acute sale"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we put for the acute sale reason?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope! We don't need that this time. Thanks for confirming!

@allisterakun
allisterakun marked this pull request as ready for review September 8, 2026 03:14
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Current Coverage: 99%

Mypy errors on streamline_animal_culling_inputs branch: 1164
Mypy errors on dev branch: 1164
No difference in error counts

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚨 Please update the changelog. This PR cannot be merged until changelog_WIP.md is updated.
🚨 Unauthorized changes detected in protected files. Please remove these changes if they are not intended.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Current Coverage: 99%

Mypy errors on streamline_animal_culling_inputs branch: 1164
Mypy errors on dev branch: 1164
No difference in error counts

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚨 Unauthorized changes detected in protected files. Please remove these changes if they are not intended.

@gmg228 gmg228 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are really close!
I added a comment yesterday about adjusting to include risk of death/sale in early lactation vs. later (splitting at 50 days in lactation). I left some responses to your questions too that I hope help!

Thank you for getting this going so quickly.
Please let me know if we need to discuss anything further!

Comment on lines +2442 to +2450
if self.future_death_date == sys.maxsize:
if self.will_die_tomorrow():
self.future_death_date = self.days_born
self._future_death_reason = animal_constants.DEATH_CULL

if self.future_cull_date == sys.maxsize:
if self.will_be_sold_tomorrow():
self.future_cull_date = self.days_born
self.cull_reason = animal_constants.ACUTE_SALE_CULL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thank you.
A quick clarification because I can't quite tell myself - once a cow is selected for removal/death, she is not still automatically evaluate for acute sale the same day, right?

Second, could we add in the change in risk based on 'day' (essentially stage of lactation being 2 categories, fresh or not). Below is the calculation that Julie put together!

Update daily risk according to DIM category (DIM=1-49 vs. DIM>=50)
Two equations with two unknowns:
Average daily removal probability = fresh_daily_removal_prob x % fresh + other_daily_removal_prob x % other

Use an example calving interval of 400d
So % fresh = 50/400 = 0.125
% other = 1 - 0.125 = 0.875

0.0001 = fresh_daily_removal_prob x 0.125 + other_daily_removal_prob x 0.875

Fresh_daily_removal_prob = 2.55 x other_daily_removal_prob
Solve for the unknowns: rearrange + substitute
… [rearrange and substitute] …
Other_daily_removal_prob = avg_daily_removal_prob / (2.55 x % fresh + % other)
Example:
Other_daily_removal_prob = 0.00011 / (2.55*0.125 + 0.875)
= 9.215 x 10^-5
Fresh_daily_removal_prob = 2.55 x other_daily_removal_prob
= 2.55 x 9.215 x 10^-5
= 0.00023

Comment thread RUFAS/biophysical/animal/animal.py Outdated
"""Return the 0-based index into a by-parity array, capping parity 4+ at the last entry."""
return 3 if self.calves >= 4 else self.calves - 1

def will_die_tomorrow(self) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can kick around some ideas with @jadamchick

will_be_removed_due_to_death
is_selected_for_mortality (or death)
will_exit_due_to_mortality (death)
will_have_mortality_event
will_have_death_event

DISEASE_CULL = "culled for disease"
UDDER_CULL = "culled for udder"
UNKNOWN_CULL = "culled for unknown"
ACUTE_SALE_CULL = "culled for acute sale"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope! We don't need that this time. Thanks for confirming!

@allisterakun
allisterakun requested a review from gmg228 September 16, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamline culling inputs in animal.json

2 participants