lactationcurve.characteristics.lactation_curve_characteristics
Lactation Curve Characteristics (LCC) utilities.
This module derives and evaluates lactation curve characteristics (LCCs) for common lactation models using symbolic calculus (via SymPy) and fast numeric evaluation (via NumPy). Characteristics include:
- time_to_peak: day in milk (DIM) at which the model reaches its maximum (derived where the first derivative is zero).
- peak_yield: the yield at the time of peak.
- cumulative_milk_yield: total yield over a lactation horizon (default 305 days), computed as the definite integral of the model over time.
- persistency: the average slope after peak until the end of lactation (derived), or literature-based formulas (for Wood and MilkBot).
Features
- Symbolic derivation (derivative/solve and integration) cached per (model, characteristic) to avoid recomputation.
- Fallback numeric methods for robustness when the symbolic expression is not suitable for lambdification or yields invalid values.
- Works with frequentist- or Bayesian-fitted parameters via the
lactationcurve.fittingAPI.
Notes
- Units: DIM in days, milk in kg or lbs.
- Symbolic expressions can be complex; a light safety check is applied before
lambdification (
is_valid_sympy_expr) and large/invalid expressions are rejected. - The default laction curve model is the Wood model, but other models are supported as well.
Author: Meike van Leerdam Last update: 21-May-2026
1""" 2Lactation Curve Characteristics (LCC) utilities. 3 4This module derives and evaluates **lactation curve characteristics** (LCCs) for 5common lactation models using symbolic calculus (via SymPy) and fast numeric 6evaluation (via NumPy). Characteristics include: 7 8- **time_to_peak**: day in milk (DIM) at which the model reaches its maximum 9 (derived where the first derivative is zero). 10- **peak_yield**: the yield at the time of peak. 11- **cumulative_milk_yield**: total yield over a lactation horizon (default 305 days), 12 computed as the definite integral of the model over time. 13- **persistency**: the average slope after peak until the end of lactation (derived), 14 or literature-based formulas (for Wood and MilkBot). 15 16Features 17-------- 18- **Symbolic derivation** (derivative/solve and integration) **cached** per 19 (model, characteristic) to avoid recomputation. 20- **Fallback numeric methods** for robustness when the symbolic expression is 21 not suitable for lambdification or yields invalid values. 22- Works with frequentist- or Bayesian-fitted parameters via the 23 `lactationcurve.fitting` API. 24 25Notes 26----- 27- Units: DIM in days, milk in kg or lbs. 28- Symbolic expressions can be complex; a light safety check is applied before 29 lambdification (`is_valid_sympy_expr`) and large/invalid expressions are rejected. 30- The default laction curve model is the Wood model, but other models are supported as well. 31 32 33Author: Meike van Leerdam 34Last update: 21-May-2026 35""" 36 37import numpy as np 38from sympy import ( 39 Integral, 40 diff, 41 exp, 42 integrate, 43 lambdify, 44 ln, 45 log, 46 nan, 47 oo, 48 simplify, 49 solve, 50 symbols, 51 zoo, 52) 53 54from lactationcurve.fitting import ( 55 bayesian_fit_milkbot_single_lactation, 56 fit_lactation_curve, 57 get_lc_parameters, 58) 59from lactationcurve.preprocessing import validate_and_prepare_inputs 60 61 62# safe guard for extreme expressions or weird results 63def is_valid_sympy_expr(expr) -> bool: 64 """Check whether a SymPy expression looks safe to lambdify. 65 66 The validation rejects expressions that contain infinities, NaNs, or an 67 excessive number of operations. 68 69 Args: 70 expr: A SymPy expression to validate. 71 72 Returns: 73 True if the expression appears safe to lambdify; False otherwise. 74 75 Notes: 76 - Expressions containing `oo`, `-oo`, `zoo`, or `nan` are rejected. 77 - Expressions with `count_ops() > 5000` are rejected as too large/complex. 78 """ 79 try: 80 if expr.has(oo, -oo, zoo, nan): 81 return False 82 # reject absurdly large expressions 83 if expr.count_ops() > 5000: 84 return False 85 return True 86 except Exception: 87 return False 88 89 90# Store derived function in cache so they do not need to be calculated all the time again. 91_LCC_CACHE = {} 92 93 94def lactation_curve_characteristic_function( 95 model="wood", characteristic=None, lactation_length=305 96) -> tuple: 97 """Build (or fetch from cache) a symbolic expression and fast numeric function for an LCC. 98 99 This function derives the requested **lactation curve characteristic** for a given 100 model using SymPy (derivative / root finding / integration). It returns the symbolic 101 expression, the tuple of parameter symbols (argument order), and a lambdified 102 numeric function that can be evaluated with numerical parameters. 103 104 The symbolic derivation and integration are done only once per (model, characteristic) 105 and then **cached** for reuse. 106 107 Args: 108 model (str): Model name. Options: 109 'milkbot', 'wood', 'wilmink', 'ali_schaeffer', 'fischer', 110 'brody', 'sikka', 'nelder', 'dhanoa', 'emmans', 'hayashi', 111 'rook', 'dijkstra', 'prasad'. 112 characteristic (str | None): Desired characteristic. Options: 113 'time_to_peak', 'peak_yield', 'cumulative_milk_yield', 'persistency'. 114 If `None` or unrecognized, a dict of all available characteristics is returned 115 (with `persistency` possibly `None` if derivation is not feasible). 116 lactation_length (int): Length of lactation in days used in persistency 117 and cumulative milk yield computation (default 305). 118 119 Returns: 120 tuple: 121 expr: SymPy expression (or dict of expressions if `characteristic` is None). 122 params: Tuple of SymPy symbols for model parameters (argument order). 123 func: Lambdified numeric function `f(*params)` (or dict of functions). 124 125 Raises: 126 Exception: If the model is unknown, or if no positive real solution for 127 peak timing/yield exists where required. In this case None is returned 128 for the characteristic, 129 and the function can be evaluated with numeric methods as a fallback. 130 """ 131 # check the cache 132 storage = (model, characteristic) 133 if storage in _LCC_CACHE: 134 return ( 135 _LCC_CACHE[storage]["expr"], 136 _LCC_CACHE[storage]["params"], 137 _LCC_CACHE[storage]["func"], 138 ) 139 140 # make sure model is all lowercase 141 model = model.lower() 142 143 # define functions 144 if model == "brody": 145 # === BRODY 1 === 146 a, b, k1, k2, t = symbols("a b k1 k2 t", real=True, positive=True) 147 function = a * exp(-k1 * t) - b * exp(-k2 * t) 148 149 elif model == "sikka": 150 # === SIKKA === 151 a, b, c, t = symbols("a b c t", real=True, positive=True) 152 function = a * exp(b * t - c * t**2) 153 154 elif model == "fischer": 155 # === FISCHER === 156 a, b, c, t = symbols("a b c t", real=True, positive=True) 157 function = a - b * t - a * exp(-c * t) 158 159 elif model == "nelder": 160 # === NELDER === 161 a, b, c, t = symbols("a b c t", real=True, positive=True) 162 function = t / (a + b * t + c * t**2) 163 164 elif model == "wood": 165 # === WOOD === 166 a, b, c, t = symbols("a b c t", real=True, positive=True) 167 function = a * t**b * exp(-c * t) 168 169 elif model == "dhanoa": 170 # === DHANOA === 171 a, b, c, t = symbols("a b c t", real=True, positive=True) 172 function = a * t ** (b * c) * exp(-c * t) 173 174 elif model == "emmans": 175 # === EMMANS === 176 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 177 function = a * exp(-exp(d - b * t)) * exp(-c * t) 178 179 elif model == "ali_schaeffer": 180 # ====ALI===== 181 a, b, c, d, k, t = symbols("a b c d k t", real=True) 182 183 t1 = t / 305 184 w1 = log(305 / t) 185 186 function = a + b * t1 + c * t1**2 + d * w1 + k * w1**2 187 188 elif model == "wilmink": 189 # === WILMINK === 190 a, c, k, t = symbols("a c k t", real=True, positive=True) 191 b = symbols("b", real=True) # b can be negative in Wilmink 192 function = a + b * exp(-k * t) - c * t 193 194 elif model == "hayashi": 195 # === HAYASHI === 196 a, b, c, t = symbols("a b c t", real=True, positive=True) 197 function = b * (exp(-t / c) - exp(-t / (a * c))) 198 199 elif model == "rook": 200 # === ROOK === 201 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 202 function = a * (1 / (1 + b / (c + t))) * exp(-d * t) 203 204 elif model == "dijkstra": 205 # === DIJKSTRA === 206 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 207 function = a * exp((b * (1 - exp(-c * t)) / c) - d * t) 208 209 elif model == "prasad": 210 # === PRASAD === 211 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 212 function = a + b * t + c * t**2 + d / t 213 214 elif model == "milkbot": 215 # === MILKBOT === 216 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 217 function = a * (1 - exp((c - t) / b) / 2) * exp(-d * t) 218 219 else: 220 raise Exception("Unknown model") 221 222 # solve derivative for when it is zero to find the function for time of peak 223 fdiff = diff(function, t) 224 225 tpeak = None # default 226 227 valid_roots = [] 228 229 try: 230 candidate_roots = solve(fdiff, t) 231 232 for r in candidate_roots: 233 r_simplified = simplify(r) 234 235 if r_simplified.is_real is not False: 236 valid_roots.append(r_simplified) 237 238 except Exception: 239 valid_roots = [] 240 241 tpeak = valid_roots[0] if valid_roots else None 242 243 # override to prevent sympy error 244 if model == "dijkstra": 245 tpeak = simplify(log(b / d) / c) 246 247 # define the end of lactation 248 T = lactation_length # days in milk 249 250 # Persistency = average slope after peak, does not work for all models so therefore try except 251 persistency = None 252 try: 253 if tpeak: 254 tmp = (function.subs(t, T) - function.subs(t, tpeak)) / (T - tpeak) 255 tmp = tmp.cancel() # light simplification 256 if is_valid_sympy_expr(tmp): 257 persistency = tmp 258 except Exception: 259 persistency = None 260 261 if characteristic != "cumulative_milk_yield": 262 if tpeak: # Check if the list is not empty 263 peak_expr = simplify(function.subs(t, tpeak)) 264 else: 265 raise Exception("No real solution for time to peak and peak yield found") 266 267 # find function for cumulative milk yield of the lactation. 268 # A lactation length of 305 is the default, but this value can 269 # also be provided as a parameter in the characteristic function. 270 cum_my_expr = integrate(function, (t, 0, lactation_length), meijerg=False) 271 272 if cum_my_expr == 0: 273 cum_my_expr = Integral(function, (t, 0, lactation_length)) 274 275 # Sorted parameter list (exclude t) 276 params = tuple( 277 sorted([s for s in function.free_symbols if s.name != "t"], key=lambda x: x.name) 278 ) 279 280 # ---------------------------------------------------- 281 # Select requested characteristic 282 # ---------------------------------------------------- 283 if characteristic == "time_to_peak": 284 expr = tpeak 285 elif characteristic == "peak_yield": 286 expr = peak_expr 287 elif characteristic == "cumulative_milk_yield": 288 expr = cum_my_expr 289 elif characteristic == "persistency": 290 if persistency is None: 291 raise Exception("Persistency could not be computed symbolically") 292 expr = persistency 293 else: 294 # Return all four if None or 'all' 295 expr = { 296 "time_to_peak": tpeak, 297 "peak_yield": peak_expr, 298 "persistency": persistency, # possibly None 299 "cumulative_milk_yield": cum_my_expr, 300 } 301 302 # ---------------------------------------------------- 303 # Build fast numeric function with lambdify 304 # ---------------------------------------------------- 305 if isinstance(expr, dict): 306 func = { 307 name: lambdify(params, ex, modules=["numpy", "scipy"]) 308 for name, ex in expr.items() 309 if ex is not None 310 } 311 else: 312 func = lambdify(params, expr, modules=["numpy", "scipy"]) 313 314 # ---------------------------------------------------- 315 # Store in cache 316 # ---------------------------------------------------- 317 _LCC_CACHE[storage] = {"expr": expr, "params": params, "func": func} 318 319 return expr, params, func 320 321 322def calculate_characteristic( 323 dim, 324 milkrecordings, 325 model="wood", 326 characteristic="cumulative_milk_yield", 327 fitting="frequentist", 328 key=None, 329 parity=3, 330 breed="H", 331 continent="USA", 332 custom_priors=None, 333 milk_unit="kg", 334 persistency_method="derived", 335 lactation_length=305, 336) -> float | None: 337 """Evaluate a lactation curve characteristic from observed test-day data. 338 339 This function fits the requested model (frequentist or Bayesian via MilkBot), 340 retrieves model parameters, and evaluates the requested characteristic using the 341 symbolic expression (if available), falling back to numeric methods when needed. 342 343 Args: 344 dim (Int): Days in milk (DIM). 345 milkrecordings (Float): Milk recordings (kg or lbs) for each DIM. 346 model (str): Model name. Supported for this function: 347 'milkbot', 'wood', 'wilmink', 'ali_schaeffer', 'fischer'. 348 characteristic (str): One of 'time_to_peak', 'peak_yield', 349 'cumulative_milk_yield', 'persistency'. 350 fitting (str): 'frequentist' (default) or 'bayesian'. 351 key (str | None): API key for MilkBot Bayesian fitting. 352 parity (Int): Parity of the cow; values above 3 are considered as 3 (Bayesian). 353 breed (str): 'H' (Holstein) or 'J' (Jersey) (Bayesian). 354 custom_priors: provide your own priors for Bayesian fitting. 355 provide as dictionary using build_prior() function 356 to create the priors in the right format. 357 Alternative use priors from the literature provided by the string command 'CHEN' 358 milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting. 359 continent (str): 'USA' or 'EU' (Bayesian). 360 persistency_method (str): 'derived' (average slope after peak; default) or 'literature' 361 (only for Wood and MilkBot). 362 lactation_length (Int | str): Horizon for persistency calculation: 363 305 (default), 'max' (use max DIM in data), or an integer. 364 365 Returns: 366 float | None: The requested characteristic value. 367 Or None if value is negative (except persistency) 368 or cannot be computed symbolically. 369 370 Raises: 371 Exception: If inputs are invalid, the model/characteristic is unsupported, 372 an API key is missing for Bayesian fitting, or the characteristic cannot be computed. 373 """ 374 # check and prepare input 375 inputs = validate_and_prepare_inputs( 376 dim, 377 milkrecordings, 378 model=model, 379 fitting=fitting, 380 breed=breed, 381 parity=parity, 382 continent=continent, 383 custom_priors=custom_priors, 384 milk_unit=milk_unit, 385 persistency_method=persistency_method, 386 lactation_length=lactation_length, 387 ) 388 389 dim = inputs.dim 390 milkrecordings = inputs.milkrecordings 391 model = inputs.model 392 fitting = inputs.fitting 393 breed = inputs.breed 394 parity = inputs.parity 395 continent = inputs.continent 396 custom_priors = inputs.custom_priors 397 milk_unit = inputs.milk_unit 398 persistency_method = inputs.persistency_method 399 lactation_length = inputs.lactation_length 400 401 # After validation, these fields are guaranteed non-None for the paths below 402 assert model is not None 403 assert fitting is not None 404 405 if model not in ["milkbot", "wood", "wilmink", "ali_schaeffer", "fischer"]: 406 msg = ( 407 "this function currently only works for" 408 " the milkbot, wood, wilmink," 409 " ali_schaeffer and fischer models" 410 ) 411 raise Exception(msg) 412 413 characteristic_options: list[str] = [ 414 "time_to_peak", 415 "peak_yield", 416 "cumulative_milk_yield", 417 "persistency", 418 ] 419 420 if characteristic in characteristic_options: 421 if fitting == "frequentist": 422 # Get fitted parameters from your fitting function 423 fitted_params = get_lc_parameters(dim, milkrecordings, model) 424 assert fitted_params is not None 425 426 if characteristic != "persistency": 427 # Try symbolic formula first 428 assert isinstance(lactation_length, int) 429 try: 430 expr, params, fn = lactation_curve_characteristic_function( 431 model, characteristic, lactation_length 432 ) 433 with np.errstate( 434 divide="ignore", invalid="ignore" 435 ): # get rid of warnings for invalid operations 436 value = fn(*fitted_params) 437 except Exception: 438 value = None 439 440 # If symbolic formula fails or is invalid (use numeric approach) 441 if ( 442 value is None 443 or not np.isfinite(value) 444 or (characteristic == "time_to_peak" and value <= 0) 445 ): 446 assert parity is not None 447 assert breed is not None 448 assert continent is not None 449 if characteristic == "time_to_peak": 450 value = numeric_time_to_peak( 451 dim, 452 milkrecordings, 453 model, 454 fitting=fitting, 455 key=key, 456 parity=parity, 457 breed=breed, 458 continent=continent, 459 ) 460 elif characteristic == "peak_yield": 461 value = numeric_peak_yield( 462 dim, 463 milkrecordings, 464 model, 465 fitting=fitting, 466 key=key, 467 parity=parity, 468 breed=breed, 469 continent=continent, 470 ) 471 elif characteristic == "cumulative_milk_yield": 472 value = numeric_cumulative_yield( 473 dim, 474 milkrecordings, 475 model, 476 fitting=fitting, 477 lactation_length=lactation_length, 478 key=key, 479 parity=parity, 480 breed=breed, 481 continent=continent, 482 ) 483 484 else: 485 if persistency_method == "derived": 486 # find lactation length from data 487 if lactation_length == "max": 488 lactation_length = max(dim) 489 elif isinstance(lactation_length, int): 490 lactation_length = lactation_length 491 else: 492 lactation_length = 305 493 value = persistency_fitted_curve( 494 dim, 495 milkrecordings, 496 model, 497 fitting="frequentist", 498 lactation_length=lactation_length, 499 ) 500 501 else: 502 if model == "wood": 503 value = persistency_wood(fitted_params[1], fitted_params[2]) 504 505 elif model == "milkbot": 506 assert len(fitted_params) >= 4 507 value = persistency_milkbot(fitted_params[3]) 508 509 else: 510 msg = ( 511 "Currently only the Wood model" 512 " and MilkBot model have a" 513 " separate model function from" 514 " the literature integrated for" 515 " persistency. if" 516 ' persistency="derived" is' 517 " selected, persistency can be" 518 " calculated for every model as" 519 " the average slope of the" 520 " lactation after the peak." 521 ) 522 raise Exception(msg) 523 try: 524 if value is None: 525 return None 526 if characteristic != "persistency" and value < 0: 527 return None 528 return float(value) 529 except ValueError: 530 raise Exception("Could not compute characteristic") 531 532 else: 533 if model == "milkbot": 534 if key is None: 535 raise Exception("Key needed to use Bayesian fitting engine MilkBot") 536 else: 537 assert parity is not None 538 assert breed is not None 539 assert continent is not None 540 assert isinstance(milk_unit, str) 541 fitted_params_bayes = bayesian_fit_milkbot_single_lactation( 542 dim, 543 milkrecordings, 544 key=key, 545 parity=parity, 546 breed=breed, 547 custom_priors=custom_priors, 548 continent=continent, 549 milk_unit=milk_unit, 550 ) 551 fitted_params_bayes = ( 552 fitted_params_bayes["scale"], 553 fitted_params_bayes["ramp"], 554 fitted_params_bayes["offset"], 555 fitted_params_bayes["decay"], 556 ) 557 558 if characteristic != "persistency": 559 # Get the symbolic expression and model parameters 560 try: 561 expr, param_symbols, fn = lactation_curve_characteristic_function( 562 model, characteristic 563 ) 564 with np.errstate(divide="ignore", invalid="ignore"): 565 value = fn(*fitted_params_bayes) 566 except Exception: 567 value = None 568 569 if ( 570 value is None 571 or not np.isfinite(value) 572 or (characteristic == "time_to_peak" and value <= 0) 573 ): 574 if characteristic == "time_to_peak": 575 value = numeric_time_to_peak( 576 dim, 577 milkrecordings, 578 model, 579 fitting=fitting, 580 key=key, 581 parity=parity, 582 breed=breed, 583 custom_priors=custom_priors, 584 milk_unit=milk_unit, 585 continent=continent, 586 ) 587 elif characteristic == "peak_yield": 588 value = numeric_peak_yield( 589 dim, 590 milkrecordings, 591 model, 592 fitting=fitting, 593 key=key, 594 parity=parity, 595 breed=breed, 596 custom_priors=custom_priors, 597 milk_unit=milk_unit, 598 continent=continent, 599 ) 600 elif characteristic == "cumulative_milk_yield": 601 if lactation_length == "max": 602 numeric_lactation_length = max(dim) 603 elif isinstance(lactation_length, int): 604 numeric_lactation_length = lactation_length 605 else: 606 numeric_lactation_length = 305 607 value = numeric_cumulative_yield( 608 dim, 609 milkrecordings, 610 model, 611 fitting=fitting, 612 lactation_length=numeric_lactation_length, 613 key=key, 614 parity=parity, 615 breed=breed, 616 custom_priors=custom_priors, 617 milk_unit=milk_unit, 618 continent=continent, 619 ) 620 621 else: 622 if persistency_method == "derived": 623 # find lactation length from data 624 if lactation_length == "max": 625 lactation_length = max(dim) 626 elif isinstance(lactation_length, int): 627 lactation_length = lactation_length 628 else: 629 lactation_length = 305 630 assert isinstance(milk_unit, str) 631 value = persistency_fitted_curve( 632 dim, 633 milkrecordings, 634 model, 635 fitting="bayesian", 636 key=key, 637 parity=parity, 638 breed=breed, 639 custom_priors=custom_priors, 640 milk_unit=milk_unit, 641 continent=continent, 642 lactation_length=lactation_length, 643 ) 644 645 else: 646 value = persistency_milkbot(fitted_params_bayes[3]) 647 648 try: 649 if value is None: 650 return None 651 if characteristic != "persistency" and value < 0: 652 return None 653 return float(value) 654 except ValueError: 655 raise Exception("Could not compute characteristic") 656 else: 657 raise Exception("Bayesian fitting is currently only implemented for MilkBot models") 658 659 else: 660 raise Exception("Unknown characteristic") 661 662 663# also define numeric approaches as back up if symbolic functions fail or throw invalid results 664def numeric_time_to_peak( 665 dim, 666 milkrecordings, 667 model, 668 fitting="frequentist", 669 key=None, 670 parity=3, 671 breed="H", 672 custom_priors=None, 673 milk_unit="kg", 674 continent="USA", 675) -> int: 676 """Compute time to peak using a numeric approach. 677 678 Fits the curve (frequentist or Bayesian), evaluates the predicted yields, 679 and returns the DIM corresponding to the maximum predicted yield. 680 681 Args: 682 dim: DIM values. 683 milkrecordings: Milk recordings (kg). 684 model: Model name. 685 fitting: 'frequentist' or 'bayesian'. 686 key: API key for MilkBot (Bayesian). 687 parity: Parity for Bayesian fitting. 688 breed: Breed for Bayesian fitting ('H' or 'J'). 689 custom_priors: provide your own priors for Bayesian fitting. 690 provide as dictionary using build_prior() function to set 691 the priors in the right format. Alternative use priors from 692 the literature provided by the string command 'CHEN' 693 milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting. 694 continent: Prior source for Bayesian ('USA', 'EU'). 695 696 697 Returns: 698 int: DIM at which the curve attains its maximum (1-indexed). 699 """ 700 # Fit the curve to get predicted milk yields 701 yields = fit_lactation_curve( 702 dim, 703 milkrecordings, 704 model, 705 fitting=fitting, 706 key=key, 707 parity=parity, 708 breed=breed, 709 custom_priors=custom_priors, 710 milk_unit=milk_unit, 711 continent=continent, 712 ) 713 # Find the index of the peak yield 714 peak_idx = np.argmax(yields) 715 # Return the corresponding DIM 716 return int(peak_idx + 1) # +1 because DIM starts at 1, not 0 717 718 719def numeric_cumulative_yield( 720 dim, milkrecordings, model, fitting="frequentist", lactation_length=305, **kwargs 721) -> float: 722 """Compute cumulative milk yield numerically over a given horizon. 723 724 Adds up the fitted milk yield for the first `lactation_length` days of the 725 predicted yield curve. 726 727 Args: 728 dim: DIM values. 729 milkrecordings: Milk recordings (kg). 730 model: Model name. 731 fitting: 'frequentist' or 'bayesian'. 732 lactation_length: Number of days to integrate (default 305). 733 **kwargs: Additional arguments forwarded to `fit_lactation_curve`. 734 735 Returns: 736 float: Cumulative milk yield over the specified horizon. 737 """ 738 y = fit_lactation_curve(dim, milkrecordings, model, fitting=fitting, **kwargs) 739 return float(np.trapezoid(y[:lactation_length], dx=1)) 740 741 742def numeric_peak_yield( 743 dim, 744 milkrecordings, 745 model, 746 fitting="frequentist", 747 key=None, 748 parity=3, 749 breed="H", 750 custom_priors=None, 751 milk_unit="kg", 752 continent="USA", 753) -> float: 754 """Compute peak yield numerically from the fitted curve. 755 756 Args: 757 dim: DIM values. 758 milkrecordings: Milk recordings (kg). 759 model: Model name. 760 fitting: 'frequentist' or 'bayesian'. 761 key: API key for MilkBot (Bayesian). 762 parity: Parity for Bayesian fitting. 763 breed: Breed for Bayesian fitting ('H' or 'J'). 764 continent: Prior source for Bayesian ('USA', 'EU', 'CHEN'). 765 766 Returns: 767 float: Maximum predicted milk yield. 768 """ 769 # Fit the curve to get predicted milk yields 770 yields = fit_lactation_curve( 771 dim, 772 milkrecordings, 773 model, 774 fitting=fitting, 775 key=key, 776 parity=parity, 777 breed=breed, 778 custom_priors=custom_priors, 779 milk_unit=milk_unit, 780 continent=continent, 781 ) 782 # Find the peak yield 783 peak_yield = np.max(yields) 784 return peak_yield 785 786 787def persistency_wood(b, c) -> float: 788 """Persistency from Wood et al. (1984): `Persistency = -(b + 1) * ln(c)`. 789 790 Args: 791 b (float): Parameter `b` of the Wood model. 792 c (float): Parameter `c` of the Wood model. 793 794 Returns: 795 float: Persistency value from the Wood formula. 796 """ 797 return float(-(b + 1) * ln(c)) 798 799 800def persistency_milkbot(d) -> float: 801 """Persistency from the MilkBot model (Ehrlich, 2013): `Persistency = 0.693 / d`. 802 803 Args: 804 d (float): Parameter `d` of the MilkBot model. 805 806 Returns: 807 float: Persistency value from the MilkBot formula. 808 """ 809 return 0.693 / d 810 811 812def persistency_fitted_curve( 813 dim, 814 milkrecordings, 815 model, 816 fitting="frequentist", 817 key=None, 818 parity=3, 819 breed="H", 820 custom_priors=None, 821 milk_unit="kg", 822 continent="USA", 823 lactation_length=305, 824) -> float: 825 """Persistency as the average slope after peak until end of lactation (numeric). 826 827 This is the default approach because symbolic derivation is not feasible for 828 all models. It computes: 829 `(yield_at_end - yield_at_peak) / (lactation_length - time_to_peak)` 830 831 Args: 832 dim: DIM values. 833 milkrecordings: Milk recordings (kg). 834 model: Model name. Options include 'milkbot' (Bayesian or frequentist), 835 'wood', 'wilmink', 'ali_schaeffer', 'fischer'. 836 fitting: 'frequentist' or 'bayesian'. 837 key: API key (only for Bayesian fitting). 838 parity: Parity of the cow; values above 3 treated as 3 (Bayesian). 839 breed: 'H' or 'J' (Bayesian). 840 custom_priors: provide your own priors for Bayesian fitting. 841 provide as dictionary using build_prior() function to 842 create the priors in the right format. Alternative use 843 priors from the literature provided by the string 844 command 'CHEN' 845 milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting. 846 continent: 'USA', 'EU', or 'CHEN' (Bayesian). 847 lactation_length (int | str): 305 (default), 'max' (use max DIM), or integer. 848 849 Returns: 850 float: Average slope after the peak until end of lactation. 851 """ 852 # calculate time to peak 853 t_peak = numeric_time_to_peak( 854 dim, 855 milkrecordings, 856 model, 857 fitting=fitting, 858 key=key, 859 parity=parity, 860 breed=breed, 861 custom_priors=custom_priors, 862 milk_unit=milk_unit, 863 continent=continent, 864 ) 865 866 # determine lactation length 867 if lactation_length == "max": 868 lactation_length = max(dim) 869 elif isinstance(lactation_length, int): 870 lactation_length = lactation_length 871 else: 872 lactation_length = 305 873 874 # calculate milk yield at peak 875 yields = fit_lactation_curve( 876 dim, 877 milkrecordings, 878 model, 879 fitting=fitting, 880 key=key, 881 parity=parity, 882 breed=breed, 883 custom_priors=custom_priors, 884 milk_unit=milk_unit, 885 continent=continent, 886 ) 887 peak_yield = yields[int(t_peak) - 1] # -1 to prevent index error 888 # calculate milk yield at end of lactation 889 end_yield = yields[int(lactation_length) - 1] # -1 to prevent index error 890 891 # calculate persistency 892 persistency = (end_yield - peak_yield) / (lactation_length - t_peak) 893 return persistency 894 895 896# add main to test 897def demo(): 898 from sympy import pprint 899 900 models = [ 901 "brody", 902 "sikka", 903 "fischer", 904 "nelder", 905 "wood", 906 "dhanoa", 907 "emmans", 908 "ali_schaeffer", 909 "wilmink", 910 "hayashi", 911 "rook", 912 "dijkstra", 913 "prasad", 914 "milkbot", 915 ] 916 917 characteristics = ["time_to_peak", "peak_yield", "cumulative_milk_yield"] 918 919 # simple test parameter values (biologically reasonable-ish) 920 test_values = {"a": 10, "b": 2, "c": 0.1, "d": 1, "k": 0.05, "k1": 0.1, "k2": 0.05} 921 922 for model in models: 923 print("\n" + "=" * 60) 924 print(f"MODEL: {model.upper()}") 925 print("=" * 60) 926 927 for char in characteristics: 928 print(f"\n--- {char} ---") 929 930 try: 931 expr, params, func = lactation_curve_characteristic_function( 932 model=model, characteristic=char 933 ) 934 935 # print symbolic expression 936 print("Symbolic:") 937 pprint(expr) 938 939 # build numeric input in correct order 940 try: 941 param_values = [test_values[p.name] for p in params] 942 943 numeric_value = func(*param_values) 944 945 print("Numeric:") 946 print(numeric_value) 947 948 except Exception as e: 949 print("Numeric evaluation failed:", e) 950 951 except Exception as e: 952 print("Error:", e) 953 954 955if __name__ == "__main__": 956 demo()
64def is_valid_sympy_expr(expr) -> bool: 65 """Check whether a SymPy expression looks safe to lambdify. 66 67 The validation rejects expressions that contain infinities, NaNs, or an 68 excessive number of operations. 69 70 Args: 71 expr: A SymPy expression to validate. 72 73 Returns: 74 True if the expression appears safe to lambdify; False otherwise. 75 76 Notes: 77 - Expressions containing `oo`, `-oo`, `zoo`, or `nan` are rejected. 78 - Expressions with `count_ops() > 5000` are rejected as too large/complex. 79 """ 80 try: 81 if expr.has(oo, -oo, zoo, nan): 82 return False 83 # reject absurdly large expressions 84 if expr.count_ops() > 5000: 85 return False 86 return True 87 except Exception: 88 return False
Check whether a SymPy expression looks safe to lambdify.
The validation rejects expressions that contain infinities, NaNs, or an excessive number of operations.
Arguments:
- expr: A SymPy expression to validate.
Returns:
True if the expression appears safe to lambdify; False otherwise.
Notes:
- Expressions containing
oo,-oo,zoo, ornanare rejected.- Expressions with
count_ops() > 5000are rejected as too large/complex.
95def lactation_curve_characteristic_function( 96 model="wood", characteristic=None, lactation_length=305 97) -> tuple: 98 """Build (or fetch from cache) a symbolic expression and fast numeric function for an LCC. 99 100 This function derives the requested **lactation curve characteristic** for a given 101 model using SymPy (derivative / root finding / integration). It returns the symbolic 102 expression, the tuple of parameter symbols (argument order), and a lambdified 103 numeric function that can be evaluated with numerical parameters. 104 105 The symbolic derivation and integration are done only once per (model, characteristic) 106 and then **cached** for reuse. 107 108 Args: 109 model (str): Model name. Options: 110 'milkbot', 'wood', 'wilmink', 'ali_schaeffer', 'fischer', 111 'brody', 'sikka', 'nelder', 'dhanoa', 'emmans', 'hayashi', 112 'rook', 'dijkstra', 'prasad'. 113 characteristic (str | None): Desired characteristic. Options: 114 'time_to_peak', 'peak_yield', 'cumulative_milk_yield', 'persistency'. 115 If `None` or unrecognized, a dict of all available characteristics is returned 116 (with `persistency` possibly `None` if derivation is not feasible). 117 lactation_length (int): Length of lactation in days used in persistency 118 and cumulative milk yield computation (default 305). 119 120 Returns: 121 tuple: 122 expr: SymPy expression (or dict of expressions if `characteristic` is None). 123 params: Tuple of SymPy symbols for model parameters (argument order). 124 func: Lambdified numeric function `f(*params)` (or dict of functions). 125 126 Raises: 127 Exception: If the model is unknown, or if no positive real solution for 128 peak timing/yield exists where required. In this case None is returned 129 for the characteristic, 130 and the function can be evaluated with numeric methods as a fallback. 131 """ 132 # check the cache 133 storage = (model, characteristic) 134 if storage in _LCC_CACHE: 135 return ( 136 _LCC_CACHE[storage]["expr"], 137 _LCC_CACHE[storage]["params"], 138 _LCC_CACHE[storage]["func"], 139 ) 140 141 # make sure model is all lowercase 142 model = model.lower() 143 144 # define functions 145 if model == "brody": 146 # === BRODY 1 === 147 a, b, k1, k2, t = symbols("a b k1 k2 t", real=True, positive=True) 148 function = a * exp(-k1 * t) - b * exp(-k2 * t) 149 150 elif model == "sikka": 151 # === SIKKA === 152 a, b, c, t = symbols("a b c t", real=True, positive=True) 153 function = a * exp(b * t - c * t**2) 154 155 elif model == "fischer": 156 # === FISCHER === 157 a, b, c, t = symbols("a b c t", real=True, positive=True) 158 function = a - b * t - a * exp(-c * t) 159 160 elif model == "nelder": 161 # === NELDER === 162 a, b, c, t = symbols("a b c t", real=True, positive=True) 163 function = t / (a + b * t + c * t**2) 164 165 elif model == "wood": 166 # === WOOD === 167 a, b, c, t = symbols("a b c t", real=True, positive=True) 168 function = a * t**b * exp(-c * t) 169 170 elif model == "dhanoa": 171 # === DHANOA === 172 a, b, c, t = symbols("a b c t", real=True, positive=True) 173 function = a * t ** (b * c) * exp(-c * t) 174 175 elif model == "emmans": 176 # === EMMANS === 177 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 178 function = a * exp(-exp(d - b * t)) * exp(-c * t) 179 180 elif model == "ali_schaeffer": 181 # ====ALI===== 182 a, b, c, d, k, t = symbols("a b c d k t", real=True) 183 184 t1 = t / 305 185 w1 = log(305 / t) 186 187 function = a + b * t1 + c * t1**2 + d * w1 + k * w1**2 188 189 elif model == "wilmink": 190 # === WILMINK === 191 a, c, k, t = symbols("a c k t", real=True, positive=True) 192 b = symbols("b", real=True) # b can be negative in Wilmink 193 function = a + b * exp(-k * t) - c * t 194 195 elif model == "hayashi": 196 # === HAYASHI === 197 a, b, c, t = symbols("a b c t", real=True, positive=True) 198 function = b * (exp(-t / c) - exp(-t / (a * c))) 199 200 elif model == "rook": 201 # === ROOK === 202 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 203 function = a * (1 / (1 + b / (c + t))) * exp(-d * t) 204 205 elif model == "dijkstra": 206 # === DIJKSTRA === 207 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 208 function = a * exp((b * (1 - exp(-c * t)) / c) - d * t) 209 210 elif model == "prasad": 211 # === PRASAD === 212 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 213 function = a + b * t + c * t**2 + d / t 214 215 elif model == "milkbot": 216 # === MILKBOT === 217 a, b, c, d, t = symbols("a b c d t", real=True, positive=True) 218 function = a * (1 - exp((c - t) / b) / 2) * exp(-d * t) 219 220 else: 221 raise Exception("Unknown model") 222 223 # solve derivative for when it is zero to find the function for time of peak 224 fdiff = diff(function, t) 225 226 tpeak = None # default 227 228 valid_roots = [] 229 230 try: 231 candidate_roots = solve(fdiff, t) 232 233 for r in candidate_roots: 234 r_simplified = simplify(r) 235 236 if r_simplified.is_real is not False: 237 valid_roots.append(r_simplified) 238 239 except Exception: 240 valid_roots = [] 241 242 tpeak = valid_roots[0] if valid_roots else None 243 244 # override to prevent sympy error 245 if model == "dijkstra": 246 tpeak = simplify(log(b / d) / c) 247 248 # define the end of lactation 249 T = lactation_length # days in milk 250 251 # Persistency = average slope after peak, does not work for all models so therefore try except 252 persistency = None 253 try: 254 if tpeak: 255 tmp = (function.subs(t, T) - function.subs(t, tpeak)) / (T - tpeak) 256 tmp = tmp.cancel() # light simplification 257 if is_valid_sympy_expr(tmp): 258 persistency = tmp 259 except Exception: 260 persistency = None 261 262 if characteristic != "cumulative_milk_yield": 263 if tpeak: # Check if the list is not empty 264 peak_expr = simplify(function.subs(t, tpeak)) 265 else: 266 raise Exception("No real solution for time to peak and peak yield found") 267 268 # find function for cumulative milk yield of the lactation. 269 # A lactation length of 305 is the default, but this value can 270 # also be provided as a parameter in the characteristic function. 271 cum_my_expr = integrate(function, (t, 0, lactation_length), meijerg=False) 272 273 if cum_my_expr == 0: 274 cum_my_expr = Integral(function, (t, 0, lactation_length)) 275 276 # Sorted parameter list (exclude t) 277 params = tuple( 278 sorted([s for s in function.free_symbols if s.name != "t"], key=lambda x: x.name) 279 ) 280 281 # ---------------------------------------------------- 282 # Select requested characteristic 283 # ---------------------------------------------------- 284 if characteristic == "time_to_peak": 285 expr = tpeak 286 elif characteristic == "peak_yield": 287 expr = peak_expr 288 elif characteristic == "cumulative_milk_yield": 289 expr = cum_my_expr 290 elif characteristic == "persistency": 291 if persistency is None: 292 raise Exception("Persistency could not be computed symbolically") 293 expr = persistency 294 else: 295 # Return all four if None or 'all' 296 expr = { 297 "time_to_peak": tpeak, 298 "peak_yield": peak_expr, 299 "persistency": persistency, # possibly None 300 "cumulative_milk_yield": cum_my_expr, 301 } 302 303 # ---------------------------------------------------- 304 # Build fast numeric function with lambdify 305 # ---------------------------------------------------- 306 if isinstance(expr, dict): 307 func = { 308 name: lambdify(params, ex, modules=["numpy", "scipy"]) 309 for name, ex in expr.items() 310 if ex is not None 311 } 312 else: 313 func = lambdify(params, expr, modules=["numpy", "scipy"]) 314 315 # ---------------------------------------------------- 316 # Store in cache 317 # ---------------------------------------------------- 318 _LCC_CACHE[storage] = {"expr": expr, "params": params, "func": func} 319 320 return expr, params, func
Build (or fetch from cache) a symbolic expression and fast numeric function for an LCC.
This function derives the requested lactation curve characteristic for a given model using SymPy (derivative / root finding / integration). It returns the symbolic expression, the tuple of parameter symbols (argument order), and a lambdified numeric function that can be evaluated with numerical parameters.
The symbolic derivation and integration are done only once per (model, characteristic) and then cached for reuse.
Arguments:
- model (str): Model name. Options: 'milkbot', 'wood', 'wilmink', 'ali_schaeffer', 'fischer', 'brody', 'sikka', 'nelder', 'dhanoa', 'emmans', 'hayashi', 'rook', 'dijkstra', 'prasad'.
- characteristic (str | None): Desired characteristic. Options:
'time_to_peak', 'peak_yield', 'cumulative_milk_yield', 'persistency'.
If
Noneor unrecognized, a dict of all available characteristics is returned (withpersistencypossiblyNoneif derivation is not feasible). - lactation_length (int): Length of lactation in days used in persistency and cumulative milk yield computation (default 305).
Returns:
tuple: expr: SymPy expression (or dict of expressions if
characteristicis None). params: Tuple of SymPy symbols for model parameters (argument order). func: Lambdified numeric functionf(*params)(or dict of functions).
Raises:
- Exception: If the model is unknown, or if no positive real solution for peak timing/yield exists where required. In this case None is returned for the characteristic, and the function can be evaluated with numeric methods as a fallback.
323def calculate_characteristic( 324 dim, 325 milkrecordings, 326 model="wood", 327 characteristic="cumulative_milk_yield", 328 fitting="frequentist", 329 key=None, 330 parity=3, 331 breed="H", 332 continent="USA", 333 custom_priors=None, 334 milk_unit="kg", 335 persistency_method="derived", 336 lactation_length=305, 337) -> float | None: 338 """Evaluate a lactation curve characteristic from observed test-day data. 339 340 This function fits the requested model (frequentist or Bayesian via MilkBot), 341 retrieves model parameters, and evaluates the requested characteristic using the 342 symbolic expression (if available), falling back to numeric methods when needed. 343 344 Args: 345 dim (Int): Days in milk (DIM). 346 milkrecordings (Float): Milk recordings (kg or lbs) for each DIM. 347 model (str): Model name. Supported for this function: 348 'milkbot', 'wood', 'wilmink', 'ali_schaeffer', 'fischer'. 349 characteristic (str): One of 'time_to_peak', 'peak_yield', 350 'cumulative_milk_yield', 'persistency'. 351 fitting (str): 'frequentist' (default) or 'bayesian'. 352 key (str | None): API key for MilkBot Bayesian fitting. 353 parity (Int): Parity of the cow; values above 3 are considered as 3 (Bayesian). 354 breed (str): 'H' (Holstein) or 'J' (Jersey) (Bayesian). 355 custom_priors: provide your own priors for Bayesian fitting. 356 provide as dictionary using build_prior() function 357 to create the priors in the right format. 358 Alternative use priors from the literature provided by the string command 'CHEN' 359 milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting. 360 continent (str): 'USA' or 'EU' (Bayesian). 361 persistency_method (str): 'derived' (average slope after peak; default) or 'literature' 362 (only for Wood and MilkBot). 363 lactation_length (Int | str): Horizon for persistency calculation: 364 305 (default), 'max' (use max DIM in data), or an integer. 365 366 Returns: 367 float | None: The requested characteristic value. 368 Or None if value is negative (except persistency) 369 or cannot be computed symbolically. 370 371 Raises: 372 Exception: If inputs are invalid, the model/characteristic is unsupported, 373 an API key is missing for Bayesian fitting, or the characteristic cannot be computed. 374 """ 375 # check and prepare input 376 inputs = validate_and_prepare_inputs( 377 dim, 378 milkrecordings, 379 model=model, 380 fitting=fitting, 381 breed=breed, 382 parity=parity, 383 continent=continent, 384 custom_priors=custom_priors, 385 milk_unit=milk_unit, 386 persistency_method=persistency_method, 387 lactation_length=lactation_length, 388 ) 389 390 dim = inputs.dim 391 milkrecordings = inputs.milkrecordings 392 model = inputs.model 393 fitting = inputs.fitting 394 breed = inputs.breed 395 parity = inputs.parity 396 continent = inputs.continent 397 custom_priors = inputs.custom_priors 398 milk_unit = inputs.milk_unit 399 persistency_method = inputs.persistency_method 400 lactation_length = inputs.lactation_length 401 402 # After validation, these fields are guaranteed non-None for the paths below 403 assert model is not None 404 assert fitting is not None 405 406 if model not in ["milkbot", "wood", "wilmink", "ali_schaeffer", "fischer"]: 407 msg = ( 408 "this function currently only works for" 409 " the milkbot, wood, wilmink," 410 " ali_schaeffer and fischer models" 411 ) 412 raise Exception(msg) 413 414 characteristic_options: list[str] = [ 415 "time_to_peak", 416 "peak_yield", 417 "cumulative_milk_yield", 418 "persistency", 419 ] 420 421 if characteristic in characteristic_options: 422 if fitting == "frequentist": 423 # Get fitted parameters from your fitting function 424 fitted_params = get_lc_parameters(dim, milkrecordings, model) 425 assert fitted_params is not None 426 427 if characteristic != "persistency": 428 # Try symbolic formula first 429 assert isinstance(lactation_length, int) 430 try: 431 expr, params, fn = lactation_curve_characteristic_function( 432 model, characteristic, lactation_length 433 ) 434 with np.errstate( 435 divide="ignore", invalid="ignore" 436 ): # get rid of warnings for invalid operations 437 value = fn(*fitted_params) 438 except Exception: 439 value = None 440 441 # If symbolic formula fails or is invalid (use numeric approach) 442 if ( 443 value is None 444 or not np.isfinite(value) 445 or (characteristic == "time_to_peak" and value <= 0) 446 ): 447 assert parity is not None 448 assert breed is not None 449 assert continent is not None 450 if characteristic == "time_to_peak": 451 value = numeric_time_to_peak( 452 dim, 453 milkrecordings, 454 model, 455 fitting=fitting, 456 key=key, 457 parity=parity, 458 breed=breed, 459 continent=continent, 460 ) 461 elif characteristic == "peak_yield": 462 value = numeric_peak_yield( 463 dim, 464 milkrecordings, 465 model, 466 fitting=fitting, 467 key=key, 468 parity=parity, 469 breed=breed, 470 continent=continent, 471 ) 472 elif characteristic == "cumulative_milk_yield": 473 value = numeric_cumulative_yield( 474 dim, 475 milkrecordings, 476 model, 477 fitting=fitting, 478 lactation_length=lactation_length, 479 key=key, 480 parity=parity, 481 breed=breed, 482 continent=continent, 483 ) 484 485 else: 486 if persistency_method == "derived": 487 # find lactation length from data 488 if lactation_length == "max": 489 lactation_length = max(dim) 490 elif isinstance(lactation_length, int): 491 lactation_length = lactation_length 492 else: 493 lactation_length = 305 494 value = persistency_fitted_curve( 495 dim, 496 milkrecordings, 497 model, 498 fitting="frequentist", 499 lactation_length=lactation_length, 500 ) 501 502 else: 503 if model == "wood": 504 value = persistency_wood(fitted_params[1], fitted_params[2]) 505 506 elif model == "milkbot": 507 assert len(fitted_params) >= 4 508 value = persistency_milkbot(fitted_params[3]) 509 510 else: 511 msg = ( 512 "Currently only the Wood model" 513 " and MilkBot model have a" 514 " separate model function from" 515 " the literature integrated for" 516 " persistency. if" 517 ' persistency="derived" is' 518 " selected, persistency can be" 519 " calculated for every model as" 520 " the average slope of the" 521 " lactation after the peak." 522 ) 523 raise Exception(msg) 524 try: 525 if value is None: 526 return None 527 if characteristic != "persistency" and value < 0: 528 return None 529 return float(value) 530 except ValueError: 531 raise Exception("Could not compute characteristic") 532 533 else: 534 if model == "milkbot": 535 if key is None: 536 raise Exception("Key needed to use Bayesian fitting engine MilkBot") 537 else: 538 assert parity is not None 539 assert breed is not None 540 assert continent is not None 541 assert isinstance(milk_unit, str) 542 fitted_params_bayes = bayesian_fit_milkbot_single_lactation( 543 dim, 544 milkrecordings, 545 key=key, 546 parity=parity, 547 breed=breed, 548 custom_priors=custom_priors, 549 continent=continent, 550 milk_unit=milk_unit, 551 ) 552 fitted_params_bayes = ( 553 fitted_params_bayes["scale"], 554 fitted_params_bayes["ramp"], 555 fitted_params_bayes["offset"], 556 fitted_params_bayes["decay"], 557 ) 558 559 if characteristic != "persistency": 560 # Get the symbolic expression and model parameters 561 try: 562 expr, param_symbols, fn = lactation_curve_characteristic_function( 563 model, characteristic 564 ) 565 with np.errstate(divide="ignore", invalid="ignore"): 566 value = fn(*fitted_params_bayes) 567 except Exception: 568 value = None 569 570 if ( 571 value is None 572 or not np.isfinite(value) 573 or (characteristic == "time_to_peak" and value <= 0) 574 ): 575 if characteristic == "time_to_peak": 576 value = numeric_time_to_peak( 577 dim, 578 milkrecordings, 579 model, 580 fitting=fitting, 581 key=key, 582 parity=parity, 583 breed=breed, 584 custom_priors=custom_priors, 585 milk_unit=milk_unit, 586 continent=continent, 587 ) 588 elif characteristic == "peak_yield": 589 value = numeric_peak_yield( 590 dim, 591 milkrecordings, 592 model, 593 fitting=fitting, 594 key=key, 595 parity=parity, 596 breed=breed, 597 custom_priors=custom_priors, 598 milk_unit=milk_unit, 599 continent=continent, 600 ) 601 elif characteristic == "cumulative_milk_yield": 602 if lactation_length == "max": 603 numeric_lactation_length = max(dim) 604 elif isinstance(lactation_length, int): 605 numeric_lactation_length = lactation_length 606 else: 607 numeric_lactation_length = 305 608 value = numeric_cumulative_yield( 609 dim, 610 milkrecordings, 611 model, 612 fitting=fitting, 613 lactation_length=numeric_lactation_length, 614 key=key, 615 parity=parity, 616 breed=breed, 617 custom_priors=custom_priors, 618 milk_unit=milk_unit, 619 continent=continent, 620 ) 621 622 else: 623 if persistency_method == "derived": 624 # find lactation length from data 625 if lactation_length == "max": 626 lactation_length = max(dim) 627 elif isinstance(lactation_length, int): 628 lactation_length = lactation_length 629 else: 630 lactation_length = 305 631 assert isinstance(milk_unit, str) 632 value = persistency_fitted_curve( 633 dim, 634 milkrecordings, 635 model, 636 fitting="bayesian", 637 key=key, 638 parity=parity, 639 breed=breed, 640 custom_priors=custom_priors, 641 milk_unit=milk_unit, 642 continent=continent, 643 lactation_length=lactation_length, 644 ) 645 646 else: 647 value = persistency_milkbot(fitted_params_bayes[3]) 648 649 try: 650 if value is None: 651 return None 652 if characteristic != "persistency" and value < 0: 653 return None 654 return float(value) 655 except ValueError: 656 raise Exception("Could not compute characteristic") 657 else: 658 raise Exception("Bayesian fitting is currently only implemented for MilkBot models") 659 660 else: 661 raise Exception("Unknown characteristic")
Evaluate a lactation curve characteristic from observed test-day data.
This function fits the requested model (frequentist or Bayesian via MilkBot), retrieves model parameters, and evaluates the requested characteristic using the symbolic expression (if available), falling back to numeric methods when needed.
Arguments:
- dim (Int): Days in milk (DIM).
- milkrecordings (Float): Milk recordings (kg or lbs) for each DIM.
- model (str): Model name. Supported for this function: 'milkbot', 'wood', 'wilmink', 'ali_schaeffer', 'fischer'.
- characteristic (str): One of 'time_to_peak', 'peak_yield', 'cumulative_milk_yield', 'persistency'.
- fitting (str): 'frequentist' (default) or 'bayesian'.
- key (str | None): API key for MilkBot Bayesian fitting.
- parity (Int): Parity of the cow; values above 3 are considered as 3 (Bayesian).
- breed (str): 'H' (Holstein) or 'J' (Jersey) (Bayesian).
- custom_priors: provide your own priors for Bayesian fitting. provide as dictionary using build_prior() function to create the priors in the right format. Alternative use priors from the literature provided by the string command 'CHEN'
- milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting.
- continent (str): 'USA' or 'EU' (Bayesian).
- persistency_method (str): 'derived' (average slope after peak; default) or 'literature' (only for Wood and MilkBot).
- lactation_length (Int | str): Horizon for persistency calculation: 305 (default), 'max' (use max DIM in data), or an integer.
Returns:
float | None: The requested characteristic value. Or None if value is negative (except persistency) or cannot be computed symbolically.
Raises:
- Exception: If inputs are invalid, the model/characteristic is unsupported, an API key is missing for Bayesian fitting, or the characteristic cannot be computed.
665def numeric_time_to_peak( 666 dim, 667 milkrecordings, 668 model, 669 fitting="frequentist", 670 key=None, 671 parity=3, 672 breed="H", 673 custom_priors=None, 674 milk_unit="kg", 675 continent="USA", 676) -> int: 677 """Compute time to peak using a numeric approach. 678 679 Fits the curve (frequentist or Bayesian), evaluates the predicted yields, 680 and returns the DIM corresponding to the maximum predicted yield. 681 682 Args: 683 dim: DIM values. 684 milkrecordings: Milk recordings (kg). 685 model: Model name. 686 fitting: 'frequentist' or 'bayesian'. 687 key: API key for MilkBot (Bayesian). 688 parity: Parity for Bayesian fitting. 689 breed: Breed for Bayesian fitting ('H' or 'J'). 690 custom_priors: provide your own priors for Bayesian fitting. 691 provide as dictionary using build_prior() function to set 692 the priors in the right format. Alternative use priors from 693 the literature provided by the string command 'CHEN' 694 milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting. 695 continent: Prior source for Bayesian ('USA', 'EU'). 696 697 698 Returns: 699 int: DIM at which the curve attains its maximum (1-indexed). 700 """ 701 # Fit the curve to get predicted milk yields 702 yields = fit_lactation_curve( 703 dim, 704 milkrecordings, 705 model, 706 fitting=fitting, 707 key=key, 708 parity=parity, 709 breed=breed, 710 custom_priors=custom_priors, 711 milk_unit=milk_unit, 712 continent=continent, 713 ) 714 # Find the index of the peak yield 715 peak_idx = np.argmax(yields) 716 # Return the corresponding DIM 717 return int(peak_idx + 1) # +1 because DIM starts at 1, not 0
Compute time to peak using a numeric approach.
Fits the curve (frequentist or Bayesian), evaluates the predicted yields, and returns the DIM corresponding to the maximum predicted yield.
Arguments:
- dim: DIM values.
- milkrecordings: Milk recordings (kg).
- model: Model name.
- fitting: 'frequentist' or 'bayesian'.
- key: API key for MilkBot (Bayesian).
- parity: Parity for Bayesian fitting.
- breed: Breed for Bayesian fitting ('H' or 'J').
- custom_priors: provide your own priors for Bayesian fitting. provide as dictionary using build_prior() function to set the priors in the right format. Alternative use priors from the literature provided by the string command 'CHEN'
- milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting.
- continent: Prior source for Bayesian ('USA', 'EU').
Returns:
int: DIM at which the curve attains its maximum (1-indexed).
720def numeric_cumulative_yield( 721 dim, milkrecordings, model, fitting="frequentist", lactation_length=305, **kwargs 722) -> float: 723 """Compute cumulative milk yield numerically over a given horizon. 724 725 Adds up the fitted milk yield for the first `lactation_length` days of the 726 predicted yield curve. 727 728 Args: 729 dim: DIM values. 730 milkrecordings: Milk recordings (kg). 731 model: Model name. 732 fitting: 'frequentist' or 'bayesian'. 733 lactation_length: Number of days to integrate (default 305). 734 **kwargs: Additional arguments forwarded to `fit_lactation_curve`. 735 736 Returns: 737 float: Cumulative milk yield over the specified horizon. 738 """ 739 y = fit_lactation_curve(dim, milkrecordings, model, fitting=fitting, **kwargs) 740 return float(np.trapezoid(y[:lactation_length], dx=1))
Compute cumulative milk yield numerically over a given horizon.
Adds up the fitted milk yield for the first lactation_length days of the
predicted yield curve.
Arguments:
- dim: DIM values.
- milkrecordings: Milk recordings (kg).
- model: Model name.
- fitting: 'frequentist' or 'bayesian'.
- lactation_length: Number of days to integrate (default 305).
- **kwargs: Additional arguments forwarded to
fit_lactation_curve.
Returns:
float: Cumulative milk yield over the specified horizon.
743def numeric_peak_yield( 744 dim, 745 milkrecordings, 746 model, 747 fitting="frequentist", 748 key=None, 749 parity=3, 750 breed="H", 751 custom_priors=None, 752 milk_unit="kg", 753 continent="USA", 754) -> float: 755 """Compute peak yield numerically from the fitted curve. 756 757 Args: 758 dim: DIM values. 759 milkrecordings: Milk recordings (kg). 760 model: Model name. 761 fitting: 'frequentist' or 'bayesian'. 762 key: API key for MilkBot (Bayesian). 763 parity: Parity for Bayesian fitting. 764 breed: Breed for Bayesian fitting ('H' or 'J'). 765 continent: Prior source for Bayesian ('USA', 'EU', 'CHEN'). 766 767 Returns: 768 float: Maximum predicted milk yield. 769 """ 770 # Fit the curve to get predicted milk yields 771 yields = fit_lactation_curve( 772 dim, 773 milkrecordings, 774 model, 775 fitting=fitting, 776 key=key, 777 parity=parity, 778 breed=breed, 779 custom_priors=custom_priors, 780 milk_unit=milk_unit, 781 continent=continent, 782 ) 783 # Find the peak yield 784 peak_yield = np.max(yields) 785 return peak_yield
Compute peak yield numerically from the fitted curve.
Arguments:
- dim: DIM values.
- milkrecordings: Milk recordings (kg).
- model: Model name.
- fitting: 'frequentist' or 'bayesian'.
- key: API key for MilkBot (Bayesian).
- parity: Parity for Bayesian fitting.
- breed: Breed for Bayesian fitting ('H' or 'J').
- continent: Prior source for Bayesian ('USA', 'EU', 'CHEN').
Returns:
float: Maximum predicted milk yield.
788def persistency_wood(b, c) -> float: 789 """Persistency from Wood et al. (1984): `Persistency = -(b + 1) * ln(c)`. 790 791 Args: 792 b (float): Parameter `b` of the Wood model. 793 c (float): Parameter `c` of the Wood model. 794 795 Returns: 796 float: Persistency value from the Wood formula. 797 """ 798 return float(-(b + 1) * ln(c))
Persistency from Wood et al. (1984): Persistency = -(b + 1) * ln(c).
Arguments:
- b (float): Parameter
bof the Wood model. - c (float): Parameter
cof the Wood model.
Returns:
float: Persistency value from the Wood formula.
801def persistency_milkbot(d) -> float: 802 """Persistency from the MilkBot model (Ehrlich, 2013): `Persistency = 0.693 / d`. 803 804 Args: 805 d (float): Parameter `d` of the MilkBot model. 806 807 Returns: 808 float: Persistency value from the MilkBot formula. 809 """ 810 return 0.693 / d
Persistency from the MilkBot model (Ehrlich, 2013): Persistency = 0.693 / d.
Arguments:
- d (float): Parameter
dof the MilkBot model.
Returns:
float: Persistency value from the MilkBot formula.
813def persistency_fitted_curve( 814 dim, 815 milkrecordings, 816 model, 817 fitting="frequentist", 818 key=None, 819 parity=3, 820 breed="H", 821 custom_priors=None, 822 milk_unit="kg", 823 continent="USA", 824 lactation_length=305, 825) -> float: 826 """Persistency as the average slope after peak until end of lactation (numeric). 827 828 This is the default approach because symbolic derivation is not feasible for 829 all models. It computes: 830 `(yield_at_end - yield_at_peak) / (lactation_length - time_to_peak)` 831 832 Args: 833 dim: DIM values. 834 milkrecordings: Milk recordings (kg). 835 model: Model name. Options include 'milkbot' (Bayesian or frequentist), 836 'wood', 'wilmink', 'ali_schaeffer', 'fischer'. 837 fitting: 'frequentist' or 'bayesian'. 838 key: API key (only for Bayesian fitting). 839 parity: Parity of the cow; values above 3 treated as 3 (Bayesian). 840 breed: 'H' or 'J' (Bayesian). 841 custom_priors: provide your own priors for Bayesian fitting. 842 provide as dictionary using build_prior() function to 843 create the priors in the right format. Alternative use 844 priors from the literature provided by the string 845 command 'CHEN' 846 milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting. 847 continent: 'USA', 'EU', or 'CHEN' (Bayesian). 848 lactation_length (int | str): 305 (default), 'max' (use max DIM), or integer. 849 850 Returns: 851 float: Average slope after the peak until end of lactation. 852 """ 853 # calculate time to peak 854 t_peak = numeric_time_to_peak( 855 dim, 856 milkrecordings, 857 model, 858 fitting=fitting, 859 key=key, 860 parity=parity, 861 breed=breed, 862 custom_priors=custom_priors, 863 milk_unit=milk_unit, 864 continent=continent, 865 ) 866 867 # determine lactation length 868 if lactation_length == "max": 869 lactation_length = max(dim) 870 elif isinstance(lactation_length, int): 871 lactation_length = lactation_length 872 else: 873 lactation_length = 305 874 875 # calculate milk yield at peak 876 yields = fit_lactation_curve( 877 dim, 878 milkrecordings, 879 model, 880 fitting=fitting, 881 key=key, 882 parity=parity, 883 breed=breed, 884 custom_priors=custom_priors, 885 milk_unit=milk_unit, 886 continent=continent, 887 ) 888 peak_yield = yields[int(t_peak) - 1] # -1 to prevent index error 889 # calculate milk yield at end of lactation 890 end_yield = yields[int(lactation_length) - 1] # -1 to prevent index error 891 892 # calculate persistency 893 persistency = (end_yield - peak_yield) / (lactation_length - t_peak) 894 return persistency
Persistency as the average slope after peak until end of lactation (numeric).
This is the default approach because symbolic derivation is not feasible for
all models. It computes:
(yield_at_end - yield_at_peak) / (lactation_length - time_to_peak)
Arguments:
- dim: DIM values.
- milkrecordings: Milk recordings (kg).
- model: Model name. Options include 'milkbot' (Bayesian or frequentist), 'wood', 'wilmink', 'ali_schaeffer', 'fischer'.
- fitting: 'frequentist' or 'bayesian'.
- key: API key (only for Bayesian fitting).
- parity: Parity of the cow; values above 3 treated as 3 (Bayesian).
- breed: 'H' or 'J' (Bayesian).
- custom_priors: provide your own priors for Bayesian fitting. provide as dictionary using build_prior() function to create the priors in the right format. Alternative use priors from the literature provided by the string command 'CHEN'
- milk_unit: Unit of milk recordings ('kg' or 'lb') for Bayesian fitting.
- continent: 'USA', 'EU', or 'CHEN' (Bayesian).
- lactation_length (int | str): 305 (default), 'max' (use max DIM), or integer.
Returns:
float: Average slope after the peak until end of lactation.
898def demo(): 899 from sympy import pprint 900 901 models = [ 902 "brody", 903 "sikka", 904 "fischer", 905 "nelder", 906 "wood", 907 "dhanoa", 908 "emmans", 909 "ali_schaeffer", 910 "wilmink", 911 "hayashi", 912 "rook", 913 "dijkstra", 914 "prasad", 915 "milkbot", 916 ] 917 918 characteristics = ["time_to_peak", "peak_yield", "cumulative_milk_yield"] 919 920 # simple test parameter values (biologically reasonable-ish) 921 test_values = {"a": 10, "b": 2, "c": 0.1, "d": 1, "k": 0.05, "k1": 0.1, "k2": 0.05} 922 923 for model in models: 924 print("\n" + "=" * 60) 925 print(f"MODEL: {model.upper()}") 926 print("=" * 60) 927 928 for char in characteristics: 929 print(f"\n--- {char} ---") 930 931 try: 932 expr, params, func = lactation_curve_characteristic_function( 933 model=model, characteristic=char 934 ) 935 936 # print symbolic expression 937 print("Symbolic:") 938 pprint(expr) 939 940 # build numeric input in correct order 941 try: 942 param_values = [test_values[p.name] for p in params] 943 944 numeric_value = func(*param_values) 945 946 print("Numeric:") 947 print(numeric_value) 948 949 except Exception as e: 950 print("Numeric evaluation failed:", e) 951 952 except Exception as e: 953 print("Error:", e)