Skip to content

CLI Module

cli

Command-line interface for pyCropWat.

Provides subcommands for: - process: Calculate effective precipitation from GEE data - aggregate: Temporal aggregation (seasonal, annual, growing season) - analyze: Statistical analysis (anomaly, trend, zonal statistics) - export: Export to NetCDF or Cloud-Optimized GeoTIFF - plot: Visualization (time series, climatology, maps)

add_common_args

add_common_args(parser: ArgumentParser) -> None

Add common arguments to a parser.

Source code in pycropwat/cli.py
def add_common_args(parser: argparse.ArgumentParser) -> None:
    """Add common arguments to a parser."""
    parser.add_argument(
        '--verbose', '-v',
        action='store_true',
        help='Enable verbose output'
    )

cmd_aggregate

cmd_aggregate(args)

Handle the 'aggregate' subcommand.

Source code in pycropwat/cli.py
def cmd_aggregate(args):
    """Handle the 'aggregate' subcommand."""
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    try:
        from .analysis import TemporalAggregator

        agg = TemporalAggregator(args.input, pattern=args.pattern)

        if args.type == 'annual':
            logger.info(f"Calculating annual {args.method} for {args.year}...")
            result = agg.annual_aggregate(
                args.year,
                method=args.method,
                output_path=args.output
            )

        elif args.type == 'seasonal':
            if not args.season:
                logger.error("--season is required for seasonal aggregation")
                sys.exit(1)
            logger.info(f"Calculating {args.season} {args.method} for {args.year}...")
            result = agg.seasonal_aggregate(
                args.year,
                args.season,
                method=args.method,
                output_path=args.output
            )

        elif args.type == 'growing-season':
            logger.info(f"Calculating growing season ({args.start_month}-{args.end_month}) {args.method} for {args.year}...")
            result = agg.growing_season_aggregate(
                args.year,
                start_month=args.start_month,
                end_month=args.end_month,
                method=args.method,
                output_path=args.output
            )

        elif args.type == 'custom':
            if not args.months:
                logger.error("--months is required for custom aggregation")
                sys.exit(1)
            logger.info(f"Calculating custom aggregation for {args.year}, months {args.months}...")
            result = agg.custom_aggregate(
                args.year,
                months=args.months,
                method=args.method,
                output_path=args.output
            )

        elif args.type == 'climatology':
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for climatology")
                sys.exit(1)
            logger.info(f"Calculating climatology for {args.start_year}-{args.end_year}...")
            result = agg.multi_year_climatology(
                args.start_year,
                args.end_year,
                months=args.months,
                output_dir=args.output
            )

        if result is not None:
            logger.info("Aggregation complete.")
        else:
            logger.warning("No data available for aggregation.")

    except Exception as e:
        logger.error(f"Error: {e}")
        if args.verbose:
            import traceback
            traceback.print_exc()
        sys.exit(1)

cmd_analyze

cmd_analyze(args)

Handle the 'analyze' subcommand.

Source code in pycropwat/cli.py
def cmd_analyze(args):
    """Handle the 'analyze' subcommand."""
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    try:
        from .analysis import StatisticalAnalyzer

        stats = StatisticalAnalyzer(args.input, pattern=args.pattern)

        if args.analysis_type == 'anomaly':
            if not all([args.year, args.month, args.clim_start, args.clim_end]):
                logger.error("--year, --month, --clim-start, and --clim-end are required for anomaly")
                sys.exit(1)
            logger.info(f"Calculating {args.anomaly_type} anomaly for {args.year}-{args.month:02d}...")
            result = stats.calculate_anomaly(
                args.year,
                args.month,
                args.clim_start,
                args.clim_end,
                anomaly_type=args.anomaly_type,
                output_path=args.output
            )

        elif args.analysis_type == 'trend':
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for trend analysis")
                sys.exit(1)
            logger.info(f"Calculating {args.trend_method} trend for {args.start_year}-{args.end_year}...")
            slope, pvalue = stats.calculate_trend(
                args.start_year,
                args.end_year,
                month=args.month,
                method=args.trend_method,
                output_dir=args.output
            )
            if slope is not None:
                logger.info("Trend analysis complete. Saved slope and p-value rasters.")

        elif args.analysis_type == 'zonal':
            if not args.zones:
                logger.error("--zones is required for zonal statistics")
                sys.exit(1)
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for zonal statistics")
                sys.exit(1)
            logger.info(f"Calculating zonal statistics for {args.start_year}-{args.end_year}...")
            df = stats.zonal_statistics(
                args.zones,
                args.start_year,
                args.end_year,
                months=args.months,
                stats=args.stats.split(',') if args.stats else ['mean', 'sum', 'min', 'max', 'std'],
                output_path=args.output
            )
            logger.info(f"Zonal statistics complete. {len(df)} records processed.")

    except Exception as e:
        logger.error(f"Error: {e}")
        if args.verbose:
            import traceback
            traceback.print_exc()
        sys.exit(1)

cmd_export

cmd_export(args)

Handle the 'export' subcommand.

Source code in pycropwat/cli.py
def cmd_export(args):
    """Handle the 'export' subcommand."""
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    try:
        if args.format == 'netcdf':
            from .analysis import export_to_netcdf
            logger.info(f"Exporting to NetCDF: {args.output}")
            export_to_netcdf(
                args.input,
                args.output,
                pattern=args.pattern,
                variable_name=args.variable or 'effective_precipitation',
                compression=not args.no_compression
            )
            logger.info("NetCDF export complete.")

        elif args.format == 'cog':
            from .analysis import export_to_cog
            logger.info(f"Converting to COG: {args.output}")
            export_to_cog(
                args.input,
                args.output,
                overview_levels=[2, 4, 8, 16]
            )
            logger.info("COG conversion complete.")

    except Exception as e:
        logger.error(f"Error: {e}")
        if args.verbose:
            import traceback
            traceback.print_exc()
        sys.exit(1)

cmd_plot

cmd_plot(args)

Handle the 'plot' subcommand.

Source code in pycropwat/cli.py
def cmd_plot(args):
    """Handle the 'plot' subcommand."""
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    try:
        from .analysis import Visualizer

        viz = Visualizer(args.input, pattern=args.pattern)

        if args.plot_type == 'timeseries':
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for time series plot")
                sys.exit(1)
            logger.info(f"Creating time series plot for {args.start_year}-{args.end_year}...")
            viz.plot_time_series(
                args.start_year,
                args.end_year,
                months=args.months,
                stat=args.stat,
                title=args.title,
                output_path=args.output,
                figsize=(args.width, args.height)
            )

        elif args.plot_type == 'climatology':
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for climatology plot")
                sys.exit(1)
            logger.info(f"Creating climatology plot for {args.start_year}-{args.end_year}...")
            viz.plot_monthly_climatology(
                args.start_year,
                args.end_year,
                stat=args.stat,
                title=args.title,
                output_path=args.output,
                figsize=(args.width, args.height)
            )

        elif args.plot_type == 'map':
            if not args.year or not args.month:
                logger.error("--year and --month are required for map plot")
                sys.exit(1)
            logger.info(f"Creating map for {args.year}-{args.month:02d}...")
            viz.plot_raster(
                args.year,
                args.month,
                cmap=args.cmap,
                title=args.title,
                output_path=args.output,
                figsize=(args.width, args.height),
                vmin=args.vmin,
                vmax=args.vmax
            )

        elif args.plot_type == 'interactive':
            if not args.year or not args.month:
                logger.error("--year and --month are required for interactive map")
                sys.exit(1)
            logger.info(f"Creating interactive map for {args.year}-{args.month:02d}...")
            viz.plot_interactive_map(
                args.year,
                args.month,
                cmap=args.cmap,
                title=args.title,
                output_path=args.output,
                zoom_start=args.zoom,
                opacity=args.opacity
            )

        elif args.plot_type == 'compare':
            if not args.year or not args.month:
                logger.error("--year and --month are required for comparison plot")
                sys.exit(1)
            if not args.other_input:
                logger.error("--other-input is required for comparison plot")
                sys.exit(1)
            logger.info(f"Creating comparison plot for {args.year}-{args.month:02d}...")
            viz.plot_comparison(
                args.year,
                args.month,
                other_dir=args.other_input,
                other_pattern=args.other_pattern,
                labels=(args.label1, args.label2),
                cmap=args.cmap,
                diff_cmap=args.diff_cmap,
                title=args.title,
                output_path=args.output,
                figsize=(args.width, args.height)
            )

        elif args.plot_type == 'scatter':
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for scatter plot")
                sys.exit(1)
            if not args.other_input:
                logger.error("--other-input is required for scatter plot")
                sys.exit(1)
            logger.info(f"Creating scatter comparison for {args.start_year}-{args.end_year}...")
            viz.plot_scatter_comparison(
                args.start_year,
                args.end_year,
                other_dir=args.other_input,
                other_pattern=args.other_pattern,
                labels=(args.label1, args.label2),
                months=args.months,
                sample_size=args.sample_size,
                title=args.title,
                output_path=args.output,
                figsize=(args.width, args.height)
            )

        elif args.plot_type == 'annual-compare':
            if not args.start_year or not args.end_year:
                logger.error("--start-year and --end-year are required for annual comparison")
                sys.exit(1)
            if not args.other_input:
                logger.error("--other-input is required for annual comparison")
                sys.exit(1)
            logger.info(f"Creating annual comparison for {args.start_year}-{args.end_year}...")
            viz.plot_annual_comparison(
                args.start_year,
                args.end_year,
                other_dir=args.other_input,
                other_pattern=args.other_pattern,
                labels=(args.label1, args.label2),
                stat=args.stat,
                title=args.title,
                output_path=args.output,
                figsize=(args.width, args.height)
            )

        logger.info(f"Plot saved to: {args.output}")

    except Exception as e:
        logger.error(f"Error: {e}")
        if args.verbose:
            import traceback
            traceback.print_exc()
        sys.exit(1)

cmd_process

cmd_process(args)

Handle the 'process' subcommand.

Source code in pycropwat/cli.py
def cmd_process(args):
    """Handle the 'process' subcommand."""
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    # Validate arguments
    # Note: PCML method doesn't require asset/band/geometry - it uses the PCML asset defaults
    if args.method != 'pcml':
        if args.asset is None or args.band is None:
            logger.error("--asset and --band are required (except for PCML method)")
            sys.exit(1)
        if args.geometry is None and args.gee_geometry is None:
            logger.error("Either --geometry or --gee-geometry must be provided (except for PCML method)")
            sys.exit(1)

    if args.geometry is not None and not Path(args.geometry).exists():
        from .utils import is_gee_asset
        if not is_gee_asset(args.geometry):
            logger.error(f"Geometry file not found: {args.geometry}")
            sys.exit(1)

    if args.start_year > args.end_year:
        logger.error("Start year must be less than or equal to end year")
        sys.exit(1)

    if args.months:
        invalid_months = [m for m in args.months if m < 1 or m > 12]
        if invalid_months:
            logger.error(f"Invalid months: {invalid_months}. Must be between 1 and 12.")
            sys.exit(1)

    # Validate USDA-SCS method requirements
    if args.method == 'usda_scs':
        if not args.awc_asset:
            logger.error("USDA-SCS method requires --awc-asset. "
                        "U.S.: projects/openet/soil/ssurgo_AWC_WTA_0to152cm_composite, "
                        "Global: projects/sat-io/open-datasets/FAO/HWSD_V2_SMU")
            sys.exit(1)
        if not args.eto_asset:
            logger.error("USDA-SCS method requires --eto-asset. "
                        "U.S.: projects/openet/assets/reference_et/conus/gridmet/monthly/v1, "
                        "Global: projects/climate-engine-pro/assets/ce-ag-era5-v2/daily (set --eto-is-daily)")
            sys.exit(1)

    # Validate ensemble method requirements (needs AWC and ETo for USDA-SCS component)
    if args.method == 'ensemble':
        if not args.awc_asset:
            logger.error("Ensemble method requires --awc-asset for USDA-SCS component. "
                        "U.S.: projects/openet/soil/ssurgo_AWC_WTA_0to152cm_composite, "
                        "Global: projects/sat-io/open-datasets/FAO/HWSD_V2_SMU")
            sys.exit(1)
        if not args.eto_asset:
            logger.error("Ensemble method requires --eto-asset for USDA-SCS/SuET components. "
                        "U.S.: projects/openet/assets/reference_et/conus/gridmet/monthly/v1, "
                        "Global: projects/climate-engine-pro/assets/ce-ag-era5-v2/daily (set --eto-is-daily)")
            sys.exit(1)

    # Validate SuET method requirements
    if args.method == 'suet':
        if not args.eto_asset:
            logger.error("SuET method requires --eto-asset. "
                        "U.S.: projects/openet/assets/reference_et/conus/gridmet/monthly/v1, "
                        "Global: projects/climate-engine-pro/assets/ce-ag-era5-v2/daily (set --eto-is-daily)")
            sys.exit(1)

    # Handle PCML method - always use default asset (overrides any user-provided values)
    if args.method == 'pcml':
        args.asset = PCML_DEFAULT_ASSET
        args.band = PCML_DEFAULT_BAND
        # Scale will be retrieved dynamically from PCML asset using nominalScale()
        args.scale = None  # Let core.py get it from asset
        logger.info(f"Using PCML default asset: {PCML_DEFAULT_ASSET}")
        logger.info(f"Using PCML default band: {PCML_DEFAULT_BAND}")
        logger.info(f"PCML native scale will be retrieved from asset using nominalScale()")
        logger.info(f"PCML annual fractions will be loaded from GEE asset: {PCML_FRACTION_ASSET}")
        if args.geometry is None and args.gee_geometry is None:
            logger.info(f"Using PCML asset geometry (entire Western U.S.)")

    try:
        logger.info(f"Initializing effective precipitation processor...")
        logger.info(f"Asset: {args.asset}")
        logger.info(f"Band: {args.band}")
        logger.info(f"Method: {args.method}")
        if args.gee_geometry:
            logger.info(f"GEE Geometry Asset: {args.gee_geometry}")
        elif args.geometry:
            logger.info(f"Geometry: {args.geometry}")
        elif args.method == 'pcml':
            logger.info(f"Geometry: Using PCML asset's built-in geometry (Western U.S.)")
        logger.info(f"Date range: {args.start_year} - {args.end_year}")

        # Build method parameters
        method_params = {}
        if args.method == 'fixed_percentage':
            method_params['percentage'] = args.percentage
        elif args.method == 'dependable_rainfall':
            method_params['probability'] = args.probability
        elif args.method == 'usda_scs':
            method_params['awc_asset'] = args.awc_asset
            method_params['awc_band'] = args.awc_band
            method_params['eto_asset'] = args.eto_asset
            method_params['eto_band'] = args.eto_band
            method_params['eto_is_daily'] = args.eto_is_daily
            method_params['rooting_depth'] = args.rooting_depth
            method_params['mad_factor'] = args.mad_factor
            band_info = f"band: {args.awc_band}" if args.awc_band else "single-band"
            logger.info(f"AWC Asset: {args.awc_asset} ({band_info})")
            logger.info(f"ETo Asset: {args.eto_asset} (band: {args.eto_band})")
            logger.info(f"Rooting Depth: {args.rooting_depth} m")
            logger.info(f"MAD Factor: {args.mad_factor}")
        elif args.method == 'suet':
            method_params['eto_asset'] = args.eto_asset
            method_params['eto_band'] = args.eto_band
            method_params['eto_is_daily'] = args.eto_is_daily
            logger.info(f"ETo Asset: {args.eto_asset} (band: {args.eto_band})")
        elif args.method == 'ensemble':
            method_params['awc_asset'] = args.awc_asset
            method_params['awc_band'] = args.awc_band
            method_params['eto_asset'] = args.eto_asset
            method_params['eto_band'] = args.eto_band
            method_params['eto_is_daily'] = args.eto_is_daily
            method_params['rooting_depth'] = args.rooting_depth
            method_params['mad_factor'] = args.mad_factor
            method_params['percentage'] = args.percentage
            method_params['probability'] = args.probability
            band_info = f"band: {args.awc_band}" if args.awc_band else "single-band"
            logger.info(f"AWC Asset: {args.awc_asset} ({band_info})")
            logger.info(f"ETo Asset: {args.eto_asset} (band: {args.eto_band})")
            logger.info(f"Rooting Depth: {args.rooting_depth} m")
            logger.info(f"MAD Factor: {args.mad_factor}")

        ep = EffectivePrecipitation(
            asset_id=args.asset,
            precip_band=args.band,
            geometry_path=args.geometry,
            start_year=args.start_year,
            end_year=args.end_year,
            scale=args.scale,
            precip_scale_factor=args.scale_factor,
            gee_project=args.project,
            gee_geometry_asset=args.gee_geometry,
            method=args.method,
            method_params=method_params
        )

        if args.sequential:
            logger.info("Processing sequentially...")
            results = ep.process_sequential(args.output, months=args.months)
        else:
            logger.info(f"Processing with {args.workers} workers...")
            results = ep.process(args.output, n_workers=args.workers, months=args.months)

        successful = sum(1 for r in results if r[0] is not None)
        logger.info(f"Processing complete. {successful}/{len(results)} months processed successfully.")

    except Exception as e:
        logger.error(f"Error: {e}")
        if args.verbose:
            import traceback
            traceback.print_exc()
        sys.exit(1)

create_parser

create_parser()

Create the argument parser with all subcommands.

Source code in pycropwat/cli.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
def create_parser():
    """Create the argument parser with all subcommands."""
    parser = argparse.ArgumentParser(
        prog='pycropwat',
        description='pyCropWat - Calculate effective precipitation from GEE climate data',
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        '--version',
        action='version',
        version='%(prog)s 1.2'
    )

    parser.add_argument(
        '--list-methods',
        action='store_true',
        help='List available effective precipitation methods and exit'
    )

    subparsers = parser.add_subparsers(dest='command', help='Available commands')

    # =========================================================================
    # PROCESS subcommand
    # =========================================================================
    process_parser = subparsers.add_parser(
        'process',
        help='Calculate effective precipitation from GEE climate data',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  pycropwat process --asset ECMWF/ERA5_LAND/MONTHLY_AGGR \\
                    --band total_precipitation_sum \\
                    --gee-geometry projects/my-project/assets/roi \\
                    --start-year 2020 --end-year 2023 \\
                    --scale-factor 1000 --output ./output

  pycropwat process --asset IDAHO_EPSCOR/TERRACLIMATE --band pr \\
                    --geometry roi.geojson \\
                    --start-year 2015 --end-year 2020 \\
                    --method fao_aglw --output ./output

  # PCML method (Western U.S. only - no asset/band/geometry required)
  pycropwat process --method pcml --start-year 2020 --end-year 2024 \\
                    --output ./WesternUS_PCML --workers 8
        """
    )
    process_parser.add_argument('--asset', '-a', help='GEE ImageCollection asset ID (not required for PCML method)')
    process_parser.add_argument('--band', '-b', help='Precipitation band name (not required for PCML method)')
    process_parser.add_argument('--geometry', '-g', type=str, help='Path to shapefile or GeoJSON')
    process_parser.add_argument('--gee-geometry', '-G', type=str, help='GEE FeatureCollection asset ID')
    process_parser.add_argument('--start-year', '-s', required=True, type=int, help='Start year')
    process_parser.add_argument('--end-year', '-e', required=True, type=int, help='End year')
    process_parser.add_argument('--output', '-o', required=True, type=Path, help='Output directory')
    process_parser.add_argument('--scale-factor', '-f', type=float, default=1.0, help='Precipitation scale factor')
    process_parser.add_argument('--scale', '-r', type=float, help='Output resolution in meters')
    process_parser.add_argument('--workers', '-w', type=int, default=4, help='Number of parallel workers')
    process_parser.add_argument('--months', '-m', type=int, nargs='+', help='Specific months to process (1-12)')
    process_parser.add_argument('--project', '-p', type=str, help='GEE project ID')
    process_parser.add_argument('--method', type=str, default='ensemble',
                               choices=['cropwat', 'fao_aglw', 'fixed_percentage', 'dependable_rainfall', 'farmwest', 'usda_scs', 'suet', 'pcml', 'ensemble'],
                               help='Effective precipitation method (pcml uses default PCML asset for Western U.S.)')
    process_parser.add_argument('--percentage', type=float, default=0.7, help='Percentage for fixed_percentage method')
    process_parser.add_argument('--probability', type=float, default=0.75, help='Probability for dependable_rainfall method')
    # USDA-SCS method parameters
    process_parser.add_argument('--awc-asset', type=str, 
                               help='GEE AWC asset for usda_scs/ensemble method. U.S.: projects/openet/soil/ssurgo_AWC_WTA_0to152cm_composite, Global: projects/sat-io/open-datasets/FAO/HWSD_V2_SMU')
    process_parser.add_argument('--awc-band', type=str, default=None,
                               help='AWC band name. Omit for SSURGO (single-band), use "AWC" for HWSD')
    process_parser.add_argument('--eto-asset', type=str,
                               help='GEE ETo asset for usda_scs/suet methods. U.S.: projects/openet/assets/reference_et/conus/gridmet/monthly/v1, Global: projects/climate-engine-pro/assets/ce-ag-era5-v2/daily')
    process_parser.add_argument('--eto-band', type=str, default='eto',
                               help='ETo band name. GridMET: "eto", ERA5: "ReferenceET_PenmanMonteith_FAO56"')
    process_parser.add_argument('--eto-is-daily', action='store_true',
                               help='Set if ETo asset is daily (will aggregate to monthly)')
    process_parser.add_argument('--rooting-depth', type=float, default=1.0,
                               help='Crop rooting depth in meters for usda_scs method (default: 1.0)')
    process_parser.add_argument('--mad-factor', type=float, default=0.5,
                               help='Management Allowed Depletion factor (0-1) for usda_scs method (default: 0.5)')
    process_parser.add_argument('--sequential', action='store_true', help='Process sequentially')
    add_common_args(process_parser)
    process_parser.set_defaults(func=cmd_process)

    # =========================================================================
    # AGGREGATE subcommand
    # =========================================================================
    agg_parser = subparsers.add_parser(
        'aggregate',
        help='Temporal aggregation of effective precipitation rasters',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Annual total
  pycropwat aggregate --input ./output --type annual --year 2020 --output ./annual_2020.tif

  # Seasonal (summer)
  pycropwat aggregate --input ./output --type seasonal --year 2020 --season JJA --output ./summer_2020.tif

  # Growing season (April-October)
  pycropwat aggregate --input ./output --type growing-season --year 2020 \\
                      --start-month 4 --end-month 10 --output ./growing_2020.tif

  # Multi-year climatology
  pycropwat aggregate --input ./output --type climatology \\
                      --start-year 2000 --end-year 2020 --output ./climatology/
        """
    )
    agg_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory with monthly rasters')
    agg_parser.add_argument('--type', '-t', required=True,
                           choices=['annual', 'seasonal', 'growing-season', 'custom', 'climatology'],
                           help='Aggregation type')
    agg_parser.add_argument('--year', '-y', type=int, help='Year to aggregate')
    agg_parser.add_argument('--start-year', type=int, help='Start year (for climatology)')
    agg_parser.add_argument('--end-year', type=int, help='End year (for climatology)')
    agg_parser.add_argument('--season', choices=['DJF', 'MAM', 'JJA', 'SON'], help='Season code')
    agg_parser.add_argument('--start-month', type=int, default=4, help='Growing season start month')
    agg_parser.add_argument('--end-month', type=int, default=10, help='Growing season end month')
    agg_parser.add_argument('--months', '-m', type=int, nargs='+', help='Specific months for custom aggregation')
    agg_parser.add_argument('--method', default='sum', choices=['sum', 'mean', 'min', 'max', 'std'],
                           help='Aggregation method')
    agg_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    agg_parser.add_argument('--output', '-o', required=True, help='Output file or directory')
    add_common_args(agg_parser)
    agg_parser.set_defaults(func=cmd_aggregate)

    # =========================================================================
    # ANALYZE subcommand
    # =========================================================================
    analyze_parser = subparsers.add_parser(
        'analyze',
        help='Statistical analysis of effective precipitation data',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Calculate anomaly
  pycropwat analyze anomaly --input ./output --year 2020 --month 6 \\
                            --clim-start 1990 --clim-end 2020 --output ./anomaly_2020_06.tif

  # Calculate trend
  pycropwat analyze trend --input ./output --start-year 2000 --end-year 2020 \\
                          --method sen --output ./trend/

  # Zonal statistics
  pycropwat analyze zonal --input ./output --zones ./regions.shp \\
                          --start-year 2000 --end-year 2020 --output ./zonal_stats.csv
        """
    )
    analyze_subparsers = analyze_parser.add_subparsers(dest='analysis_type', help='Analysis type')

    # Anomaly
    anomaly_parser = analyze_subparsers.add_parser('anomaly', help='Calculate anomaly relative to climatology')
    anomaly_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    anomaly_parser.add_argument('--year', '-y', required=True, type=int, help='Target year')
    anomaly_parser.add_argument('--month', '-m', required=True, type=int, help='Target month (1-12)')
    anomaly_parser.add_argument('--clim-start', required=True, type=int, help='Climatology start year')
    anomaly_parser.add_argument('--clim-end', required=True, type=int, help='Climatology end year')
    anomaly_parser.add_argument('--anomaly-type', default='absolute',
                               choices=['absolute', 'percent', 'standardized'],
                               help='Type of anomaly')
    anomaly_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    anomaly_parser.add_argument('--output', '-o', required=True, help='Output file')
    add_common_args(anomaly_parser)
    anomaly_parser.set_defaults(func=cmd_analyze)

    # Trend
    trend_parser = analyze_subparsers.add_parser('trend', help='Calculate temporal trend')
    trend_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    trend_parser.add_argument('--start-year', required=True, type=int, help='Start year')
    trend_parser.add_argument('--end-year', required=True, type=int, help='End year')
    trend_parser.add_argument('--month', '-m', type=int, help='Specific month (or annual if omitted)')
    trend_parser.add_argument('--trend-method', default='linear', choices=['linear', 'sen'],
                             help='Trend method (linear or Theil-Sen)')
    trend_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    trend_parser.add_argument('--output', '-o', required=True, help='Output directory')
    add_common_args(trend_parser)
    trend_parser.set_defaults(func=cmd_analyze)

    # Zonal
    zonal_parser = analyze_subparsers.add_parser('zonal', help='Calculate zonal statistics')
    zonal_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    zonal_parser.add_argument('--zones', '-z', required=True, help='Path to zones shapefile/GeoJSON')
    zonal_parser.add_argument('--start-year', required=True, type=int, help='Start year')
    zonal_parser.add_argument('--end-year', required=True, type=int, help='End year')
    zonal_parser.add_argument('--months', '-m', type=int, nargs='+', help='Specific months')
    zonal_parser.add_argument('--stats', default='mean,sum,min,max,std',
                             help='Statistics to compute (comma-separated)')
    zonal_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    zonal_parser.add_argument('--output', '-o', required=True, help='Output CSV file')
    add_common_args(zonal_parser)
    zonal_parser.set_defaults(func=cmd_analyze)

    # =========================================================================
    # EXPORT subcommand
    # =========================================================================
    export_parser = subparsers.add_parser(
        'export',
        help='Export data to different formats',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Export to NetCDF
  pycropwat export netcdf --input ./output --output ./data.nc

  # Convert to Cloud-Optimized GeoTIFF
  pycropwat export cog --input ./output/effective_precip_2020_06.tif --output ./cog_2020_06.tif
        """
    )
    export_subparsers = export_parser.add_subparsers(dest='format', help='Export format')

    # NetCDF
    netcdf_parser = export_subparsers.add_parser('netcdf', help='Export to NetCDF')
    netcdf_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    netcdf_parser.add_argument('--output', '-o', required=True, help='Output NetCDF file')
    netcdf_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    netcdf_parser.add_argument('--variable', help='Variable name in NetCDF')
    netcdf_parser.add_argument('--no-compression', action='store_true', help='Disable compression')
    add_common_args(netcdf_parser)
    netcdf_parser.set_defaults(func=cmd_export)

    # COG
    cog_parser = export_subparsers.add_parser('cog', help='Convert to Cloud-Optimized GeoTIFF')
    cog_parser.add_argument('--input', '-i', required=True, help='Input GeoTIFF file')
    cog_parser.add_argument('--output', '-o', required=True, help='Output COG file')
    add_common_args(cog_parser)
    cog_parser.set_defaults(func=cmd_export)

    # =========================================================================
    # PLOT subcommand
    # =========================================================================
    plot_parser = subparsers.add_parser(
        'plot',
        help='Create visualizations',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Time series plot
  pycropwat plot timeseries --input ./output --start-year 2000 --end-year 2020 \\
                            --output ./timeseries.png

  # Monthly climatology bar chart
  pycropwat plot climatology --input ./output --start-year 2000 --end-year 2020 \\
                             --output ./climatology.png

  # Single month map
  pycropwat plot map --input ./output --year 2020 --month 6 --output ./map_2020_06.png

  # Interactive map (requires leafmap or folium)
  pycropwat plot interactive --input ./output --year 2020 --month 6 --output ./map.html

  # Compare two datasets side-by-side
  pycropwat plot compare --input ./era5_output --other-input ./terraclimate_output \\
                         --year 2020 --month 6 --label1 ERA5 --label2 TerraClimate \\
                         --output ./comparison.png

  # Scatter plot comparison
  pycropwat plot scatter --input ./era5_output --other-input ./terraclimate_output \\
                         --start-year 2000 --end-year 2020 --output ./scatter.png

  # Annual comparison bar chart
  pycropwat plot annual-compare --input ./era5_output --other-input ./terraclimate_output \\
                                --start-year 2000 --end-year 2020 --output ./annual.png
        """
    )
    plot_subparsers = plot_parser.add_subparsers(dest='plot_type', help='Plot type')

    # Time series
    ts_parser = plot_subparsers.add_parser('timeseries', help='Time series plot')
    ts_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    ts_parser.add_argument('--start-year', required=True, type=int, help='Start year')
    ts_parser.add_argument('--end-year', required=True, type=int, help='End year')
    ts_parser.add_argument('--months', '-m', type=int, nargs='+', help='Specific months')
    ts_parser.add_argument('--stat', default='mean', choices=['mean', 'sum', 'min', 'max'],
                          help='Spatial aggregation statistic')
    ts_parser.add_argument('--title', help='Plot title')
    ts_parser.add_argument('--width', type=int, default=12, help='Figure width in inches')
    ts_parser.add_argument('--height', type=int, default=6, help='Figure height in inches')
    ts_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    ts_parser.add_argument('--output', '-o', required=True, help='Output image file')
    add_common_args(ts_parser)
    ts_parser.set_defaults(func=cmd_plot)

    # Climatology
    clim_parser = plot_subparsers.add_parser('climatology', help='Monthly climatology bar chart')
    clim_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    clim_parser.add_argument('--start-year', required=True, type=int, help='Start year')
    clim_parser.add_argument('--end-year', required=True, type=int, help='End year')
    clim_parser.add_argument('--stat', default='mean', choices=['mean', 'sum'],
                            help='Spatial aggregation statistic')
    clim_parser.add_argument('--title', help='Plot title')
    clim_parser.add_argument('--width', type=int, default=10, help='Figure width in inches')
    clim_parser.add_argument('--height', type=int, default=6, help='Figure height in inches')
    clim_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    clim_parser.add_argument('--output', '-o', required=True, help='Output image file')
    add_common_args(clim_parser)
    clim_parser.set_defaults(func=cmd_plot)

    # Map
    map_parser = plot_subparsers.add_parser('map', help='Single raster map')
    map_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    map_parser.add_argument('--year', '-y', required=True, type=int, help='Year')
    map_parser.add_argument('--month', '-m', required=True, type=int, help='Month (1-12)')
    map_parser.add_argument('--cmap', default='YlGnBu', help='Colormap name')
    map_parser.add_argument('--title', help='Plot title')
    map_parser.add_argument('--vmin', type=float, help='Minimum color scale value')
    map_parser.add_argument('--vmax', type=float, help='Maximum color scale value')
    map_parser.add_argument('--width', type=int, default=10, help='Figure width in inches')
    map_parser.add_argument('--height', type=int, default=8, help='Figure height in inches')
    map_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    map_parser.add_argument('--output', '-o', required=True, help='Output image file')
    add_common_args(map_parser)
    map_parser.set_defaults(func=cmd_plot)

    # Interactive map
    interactive_parser = plot_subparsers.add_parser('interactive', help='Interactive map (HTML)')
    interactive_parser.add_argument('--input', '-i', required=True, type=Path, help='Input directory')
    interactive_parser.add_argument('--year', '-y', required=True, type=int, help='Year')
    interactive_parser.add_argument('--month', '-m', required=True, type=int, help='Month (1-12)')
    interactive_parser.add_argument('--cmap', default='YlGnBu', help='Colormap name')
    interactive_parser.add_argument('--title', help='Map title')
    interactive_parser.add_argument('--zoom', type=int, default=6, help='Initial zoom level')
    interactive_parser.add_argument('--opacity', type=float, default=0.7, help='Layer opacity (0-1)')
    interactive_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    interactive_parser.add_argument('--output', '-o', required=True, help='Output HTML file')
    add_common_args(interactive_parser)
    interactive_parser.set_defaults(func=cmd_plot)

    # Comparison plot
    compare_parser = plot_subparsers.add_parser('compare', help='Side-by-side dataset comparison')
    compare_parser.add_argument('--input', '-i', required=True, type=Path, help='Primary input directory')
    compare_parser.add_argument('--other-input', required=True, type=Path, help='Secondary input directory')
    compare_parser.add_argument('--year', '-y', required=True, type=int, help='Year')
    compare_parser.add_argument('--month', '-m', required=True, type=int, help='Month (1-12)')
    compare_parser.add_argument('--label1', default='Dataset 1', help='Label for primary dataset')
    compare_parser.add_argument('--label2', default='Dataset 2', help='Label for secondary dataset')
    compare_parser.add_argument('--cmap', default='YlGnBu', help='Colormap for data')
    compare_parser.add_argument('--diff-cmap', default='RdBu', help='Colormap for difference')
    compare_parser.add_argument('--title', help='Plot title')
    compare_parser.add_argument('--width', type=int, default=16, help='Figure width in inches')
    compare_parser.add_argument('--height', type=int, default=5, help='Figure height in inches')
    compare_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    compare_parser.add_argument('--other-pattern', default='effective_precip_[0-9]*.tif', help='Pattern for secondary dataset')
    compare_parser.add_argument('--output', '-o', required=True, help='Output image file')
    add_common_args(compare_parser)
    compare_parser.set_defaults(func=cmd_plot)

    # Scatter plot comparison
    scatter_parser = plot_subparsers.add_parser('scatter', help='Scatter plot comparison')
    scatter_parser.add_argument('--input', '-i', required=True, type=Path, help='Primary input directory')
    scatter_parser.add_argument('--other-input', required=True, type=Path, help='Secondary input directory')
    scatter_parser.add_argument('--start-year', required=True, type=int, help='Start year')
    scatter_parser.add_argument('--end-year', required=True, type=int, help='End year')
    scatter_parser.add_argument('--months', '-m', type=int, nargs='+', help='Specific months')
    scatter_parser.add_argument('--label1', default='Dataset 1', help='Label for primary dataset')
    scatter_parser.add_argument('--label2', default='Dataset 2', help='Label for secondary dataset')
    scatter_parser.add_argument('--sample-size', type=int, default=10000, help='Max samples for plot')
    scatter_parser.add_argument('--title', help='Plot title')
    scatter_parser.add_argument('--width', type=int, default=8, help='Figure width in inches')
    scatter_parser.add_argument('--height', type=int, default=8, help='Figure height in inches')
    scatter_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    scatter_parser.add_argument('--other-pattern', default='effective_precip_[0-9]*.tif', help='Pattern for secondary dataset')
    scatter_parser.add_argument('--output', '-o', required=True, help='Output image file')
    add_common_args(scatter_parser)
    scatter_parser.set_defaults(func=cmd_plot)

    # Annual comparison
    annual_compare_parser = plot_subparsers.add_parser('annual-compare', help='Annual totals comparison')
    annual_compare_parser.add_argument('--input', '-i', required=True, type=Path, help='Primary input directory')
    annual_compare_parser.add_argument('--other-input', required=True, type=Path, help='Secondary input directory')
    annual_compare_parser.add_argument('--start-year', required=True, type=int, help='Start year')
    annual_compare_parser.add_argument('--end-year', required=True, type=int, help='End year')
    annual_compare_parser.add_argument('--label1', default='Dataset 1', help='Label for primary dataset')
    annual_compare_parser.add_argument('--label2', default='Dataset 2', help='Label for secondary dataset')
    annual_compare_parser.add_argument('--stat', default='mean', choices=['mean', 'sum'], help='Spatial statistic')
    annual_compare_parser.add_argument('--title', help='Plot title')
    annual_compare_parser.add_argument('--width', type=int, default=12, help='Figure width in inches')
    annual_compare_parser.add_argument('--height', type=int, default=6, help='Figure height in inches')
    annual_compare_parser.add_argument('--pattern', default='effective_precip_[0-9]*.tif', help='File glob pattern (use [0-9]* to exclude fraction files)')
    annual_compare_parser.add_argument('--other-pattern', default='effective_precip_[0-9]*.tif', help='Pattern for secondary dataset')
    annual_compare_parser.add_argument('--output', '-o', required=True, help='Output image file')
    add_common_args(annual_compare_parser)
    annual_compare_parser.set_defaults(func=cmd_plot)

    return parser

get_pcml_band_name

get_pcml_band_name(year: int, month: int) -> str

Get PCML band name for a specific year and month.

PCML bands are formatted as bYYYY_M where months 1-9 do not have a preceding zero. Examples: b2015_9, b2016_10

Source code in pycropwat/cli.py
def get_pcml_band_name(year: int, month: int) -> str:
    """Get PCML band name for a specific year and month.

    PCML bands are formatted as bYYYY_M where months 1-9 do not have a preceding zero.
    Examples: b2015_9, b2016_10
    """
    return f"b{year}_{month}"

main

main()

Main entry point for the CLI.

Source code in pycropwat/cli.py
def main():
    """Main entry point for the CLI."""
    parser = create_parser()
    args = parser.parse_args()

    # Handle --list-methods at top level
    if args.list_methods:
        print("\nAvailable effective precipitation methods:\n")
        for name, desc in list_available_methods().items():
            print(f"  {name:25s} {desc}")
        print()
        sys.exit(0)

    # If no command provided, check for legacy usage or show help
    if args.command is None:
        # Check if legacy arguments are provided
        if len(sys.argv) > 1 and sys.argv[1].startswith('--'):
            # Legacy mode - redirect to process command
            print("Note: Running in legacy mode. Consider using 'pycropwat process' subcommand.")
            print("Run 'pycropwat --help' for full command reference.\n")
            # Re-parse with process as default
            sys.argv.insert(1, 'process')
            args = parser.parse_args()
        else:
            parser.print_help()
            sys.exit(0)

    # Execute the subcommand
    if hasattr(args, 'func'):
        args.func(args)
    else:
        parser.print_help()
        sys.exit(0)

setup_logging

setup_logging(verbose: bool = False) -> None

Configure logging based on verbosity.

Source code in pycropwat/cli.py
def setup_logging(verbose: bool = False) -> None:
    """Configure logging based on verbosity."""
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )