|
29 | 29 | calculate_gdd_original, |
30 | 30 | ) |
31 | 31 | from .temperature import celsius_to_fahrenheit, fahrenheit_to_celsius |
| 32 | +from .winter_injury import ( |
| 33 | + get_cultivar_names, |
| 34 | + get_cultivar_parameters, |
| 35 | + load_csv_column, |
| 36 | + run_simulation, |
| 37 | +) |
32 | 38 |
|
33 | 39 |
|
34 | 40 | @click.group() |
@@ -468,5 +474,226 @@ def requirements(ctx, verbose, log_file, no_postgres_log, note): |
468 | 474 | ) |
469 | 475 |
|
470 | 476 |
|
| 477 | +@agricultural_modeling_cli.group() |
| 478 | +def winter_injury(): |
| 479 | + """Winter cereal cold hardiness (LT50) simulation commands. |
| 480 | +
|
| 481 | + Simulates the Winter Cereal Survival Model (WCSM) from Byrns et al. (2020). |
| 482 | + Predicts daily LT50 values based on crown temperature, daylength, and |
| 483 | + cultivar-specific parameters. |
| 484 | + """ |
| 485 | + pass |
| 486 | + |
| 487 | + |
| 488 | +@winter_injury.command() |
| 489 | +@click.option("--cultivar", help="Cultivar preset name (e.g. Norstar)") |
| 490 | +@add_common_options |
| 491 | +@click.pass_context |
| 492 | +@handle_common_errors("winter-injury-cultivars") |
| 493 | +def cultivars(ctx, cultivar, verbose, log_file, no_postgres_log, note): |
| 494 | + """List available cultivar presets or show details for one.""" |
| 495 | + cli_ctx = ctx.obj |
| 496 | + cli_ctx.setup("winter-injury-cultivars", verbose, log_file, no_postgres_log) |
| 497 | + |
| 498 | + if cultivar: |
| 499 | + params = get_cultivar_parameters(cultivar) |
| 500 | + click.echo(f"Parameters for {cultivar}:") |
| 501 | + click.echo(f" Type: {params['type']}") |
| 502 | + click.echo(f" Origin: {params['origin']}") |
| 503 | + click.echo(f" LT50c: {params['LT50c']}°C") |
| 504 | + click.echo(f" Vern. Req.: {params['vernReq']} days") |
| 505 | + click.echo(f" Min DD: {params['minDD'] or 'N/A (winter type)'}") |
| 506 | + click.echo(f" Photo Coeff: {params['photoCoeff']}") |
| 507 | + click.echo(f" Photo Critical: {params['photoCritical']} h") |
| 508 | + else: |
| 509 | + names = get_cultivar_names() |
| 510 | + click.echo("Available cultivar presets:") |
| 511 | + for name in names: |
| 512 | + p = get_cultivar_parameters(name) |
| 513 | + click.echo(f" {name:<20s} LT50c={p['LT50c']:6.1f}°C {p['type']}") |
| 514 | + click.echo(f"\nTotal: {len(names)} cultivars") |
| 515 | + click.echo("Use --cultivar <name> for details") |
| 516 | + |
| 517 | + |
| 518 | +@winter_injury.command() |
| 519 | +@click.option( |
| 520 | + "--crown-temp-csv", |
| 521 | + required=True, |
| 522 | + type=click.Path(exists=True), |
| 523 | + help="CSV file with crown temperature data", |
| 524 | +) |
| 525 | +@click.option( |
| 526 | + "--crown-temp-col", |
| 527 | + default="crownTemp", |
| 528 | + help="Column name for crown temperature (default: crownTemp)", |
| 529 | +) |
| 530 | +@click.option( |
| 531 | + "--daylength-csv", |
| 532 | + required=True, |
| 533 | + type=click.Path(exists=True), |
| 534 | + help="CSV file with daylength data", |
| 535 | +) |
| 536 | +@click.option( |
| 537 | + "--daylength-col", |
| 538 | + default="daylength", |
| 539 | + help="Column name for daylength (default: daylength)", |
| 540 | +) |
| 541 | +@click.option("--cultivar", help="Cultivar preset name (e.g. Norstar)") |
| 542 | +@click.option("--lt50c", type=float, help="LT50c parameter (overrides cultivar)") |
| 543 | +@click.option( |
| 544 | + "--vern-req", type=float, help="Vernalization requirement (overrides cultivar)" |
| 545 | +) |
| 546 | +@click.option("--min-dd", type=float, help="Minimum degree days (overrides cultivar)") |
| 547 | +@click.option( |
| 548 | + "--photo-coeff", type=float, help="Photoperiod coefficient (overrides cultivar)" |
| 549 | +) |
| 550 | +@click.option( |
| 551 | + "--photo-critical", |
| 552 | + type=float, |
| 553 | + default=13.5, |
| 554 | + help="Critical photoperiod (default: 13.5)", |
| 555 | +) |
| 556 | +@click.option("--output", "-o", help="Output CSV file path (default: stdout)") |
| 557 | +@add_common_options |
| 558 | +@click.pass_context |
| 559 | +@handle_common_errors("winter-injury-simulate") |
| 560 | +def simulate( |
| 561 | + ctx, |
| 562 | + crown_temp_csv, |
| 563 | + crown_temp_col, |
| 564 | + daylength_csv, |
| 565 | + daylength_col, |
| 566 | + cultivar, |
| 567 | + lt50c, |
| 568 | + vern_req, |
| 569 | + min_dd, |
| 570 | + photo_coeff, |
| 571 | + photo_critical, |
| 572 | + output, |
| 573 | + verbose, |
| 574 | + log_file, |
| 575 | + no_postgres_log, |
| 576 | + note, |
| 577 | +): |
| 578 | + """Run a winter injury (LT50) simulation. |
| 579 | +
|
| 580 | + Requires crown temperature and daylength time series as CSV files. |
| 581 | + Use a cultivar preset or specify parameters manually. |
| 582 | +
|
| 583 | + Example: |
| 584 | +
|
| 585 | + rtgs agricultural-modeling winter-injury simulate |
| 586 | + --cultivar Norstar |
| 587 | + --crown-temp-csv temps.csv --crown-temp-col crownTemp |
| 588 | + --daylength-csv daylengths.csv --daylength-col daylength |
| 589 | + -o results.csv |
| 590 | + """ |
| 591 | + import csv as csv_mod |
| 592 | + |
| 593 | + cli_ctx = ctx.obj |
| 594 | + cli_ctx.setup("winter-injury-simulate", verbose, log_file, no_postgres_log) |
| 595 | + |
| 596 | + # Build parameters from cultivar preset + overrides |
| 597 | + if cultivar: |
| 598 | + preset = get_cultivar_parameters(cultivar) |
| 599 | + params = { |
| 600 | + "LT50c": lt50c if lt50c is not None else preset["LT50c"], |
| 601 | + "vernReq": vern_req if vern_req is not None else preset["vernReq"], |
| 602 | + "minDD": min_dd if min_dd is not None else (preset["minDD"] or 370), |
| 603 | + "photoCoeff": ( |
| 604 | + photo_coeff if photo_coeff is not None else preset["photoCoeff"] |
| 605 | + ), |
| 606 | + "photoCritical": photo_critical, |
| 607 | + "initLT50": -3.0, |
| 608 | + } |
| 609 | + else: |
| 610 | + if lt50c is None: |
| 611 | + click.echo("Error: --lt50c is required when not using a cultivar preset") |
| 612 | + sys.exit(1) |
| 613 | + params = { |
| 614 | + "LT50c": lt50c, |
| 615 | + "vernReq": vern_req if vern_req is not None else 49, |
| 616 | + "minDD": min_dd if min_dd is not None else 370, |
| 617 | + "photoCoeff": photo_coeff if photo_coeff is not None else 50, |
| 618 | + "photoCritical": photo_critical, |
| 619 | + "initLT50": -3.0, |
| 620 | + } |
| 621 | + |
| 622 | + # Load input data |
| 623 | + crown_temps = load_csv_column(crown_temp_csv, crown_temp_col) |
| 624 | + daylengths = load_csv_column(daylength_csv, daylength_col) |
| 625 | + |
| 626 | + click.echo(f"Crown temps: {len(crown_temps)} days from {crown_temp_csv}") |
| 627 | + click.echo(f"Daylengths: {len(daylengths)} days from {daylength_csv}") |
| 628 | + click.echo( |
| 629 | + f"Parameters: LT50c={params['LT50c']}, vernReq={params['vernReq']}, " |
| 630 | + f"minDD={params['minDD']}, photoCoeff={params['photoCoeff']}" |
| 631 | + ) |
| 632 | + |
| 633 | + # Run simulation |
| 634 | + records = run_simulation(params, crown_temps, daylengths) |
| 635 | + |
| 636 | + click.echo(f"Simulation: {len(records)} timesteps") |
| 637 | + |
| 638 | + # Output |
| 639 | + out_fields = [ |
| 640 | + "time", |
| 641 | + "LT50", |
| 642 | + "LT50raw", |
| 643 | + "temperature", |
| 644 | + "daylength", |
| 645 | + "accAmt", |
| 646 | + "dehardAmt", |
| 647 | + "dehardAmtStress", |
| 648 | + "vernDays", |
| 649 | + "vernProg", |
| 650 | + "photoReqFraction", |
| 651 | + "mflnFraction", |
| 652 | + "respProg", |
| 653 | + "minLT50", |
| 654 | + "respiration", |
| 655 | + "vernSaturation", |
| 656 | + ] |
| 657 | + # First record (initial state) lacks diagnostics; fill them |
| 658 | + for key in ["LT50", "temperature", "daylength", "respiration", "vernSaturation"]: |
| 659 | + if key not in records[0]: |
| 660 | + records[0][key] = "" if key != "LT50" else records[0]["LT50raw"] |
| 661 | + |
| 662 | + if output: |
| 663 | + from pathlib import Path as P |
| 664 | + |
| 665 | + with open(P(output), "w", newline="") as f: |
| 666 | + writer = csv_mod.DictWriter(f, fieldnames=out_fields, extrasaction="ignore") |
| 667 | + writer.writeheader() |
| 668 | + writer.writerows(records) |
| 669 | + click.echo(f"Output: {output}") |
| 670 | + else: |
| 671 | + writer = csv_mod.DictWriter( |
| 672 | + sys.stdout, fieldnames=out_fields, extrasaction="ignore" |
| 673 | + ) |
| 674 | + writer.writeheader() |
| 675 | + writer.writerows(records) |
| 676 | + |
| 677 | + # Log |
| 678 | + parameters_dict = { |
| 679 | + "cultivar": cultivar, |
| 680 | + "crown_temp_csv": crown_temp_csv, |
| 681 | + "daylength_csv": daylength_csv, |
| 682 | + "params": params, |
| 683 | + "note": note, |
| 684 | + } |
| 685 | + results = { |
| 686 | + "success": True, |
| 687 | + "timesteps": len(records), |
| 688 | + "output_file": output or "stdout", |
| 689 | + } |
| 690 | + cli_ctx.log_success( |
| 691 | + operation=f"Winter injury simulation ({cultivar or 'custom'})", |
| 692 | + parameters=parameters_dict, |
| 693 | + results=results, |
| 694 | + script_path=__file__, |
| 695 | + ) |
| 696 | + |
| 697 | + |
471 | 698 | if __name__ == "__main__": |
472 | 699 | agricultural_modeling_cli() |
0 commit comments