Skip to content

Commit abe67c1

Browse files
authored
Merge pull request #133 from RTGS-Lab/feature/winter-injury-model
Add winter cereal cold hardiness (LT50) model
2 parents 9ec6379 + 970bf77 commit abe67c1

9 files changed

Lines changed: 2853 additions & 0 deletions

File tree

src/rtgs_lab_tools/agricultural_modeling/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,38 @@ def __getattr__(name):
103103
from .weather_api import validate_date_range
104104

105105
return validate_date_range
106+
# Winter injury model
107+
elif name == "WinterInjuryModel":
108+
from .winter_injury import WinterInjuryModel
109+
110+
return WinterInjuryModel
111+
elif name == "run_simulation":
112+
from .winter_injury import run_simulation
113+
114+
return run_simulation
115+
elif name == "get_cultivar_names":
116+
from .winter_injury import get_cultivar_names
117+
118+
return get_cultivar_names
119+
elif name == "get_cultivar_parameters":
120+
from .winter_injury import get_cultivar_parameters
121+
122+
return get_cultivar_parameters
123+
elif name == "load_csv_column":
124+
from .winter_injury import load_csv_column
125+
126+
return load_csv_column
106127
else:
107128
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
108129

109130

110131
__all__ = [
132+
# Winter injury model
133+
"WinterInjuryModel",
134+
"run_simulation",
135+
"get_cultivar_names",
136+
"get_cultivar_parameters",
137+
"load_csv_column",
111138
# Temperature conversions
112139
"celsius_to_fahrenheit",
113140
"fahrenheit_to_celsius",

src/rtgs_lab_tools/agricultural_modeling/cli.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
calculate_gdd_original,
3030
)
3131
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+
)
3238

3339

3440
@click.group()
@@ -468,5 +474,226 @@ def requirements(ctx, verbose, log_file, no_postgres_log, note):
468474
)
469475

470476

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+
471698
if __name__ == "__main__":
472699
agricultural_modeling_cli()

0 commit comments

Comments
 (0)