Skip to content

NetCDF Class#

pyramids — NetCDF, fully supported: CF / COARDS, curvilinear, staggered WRF, packed data, netCDF-4 groups and
UGRID meshes on one native GDAL stack

The NetCDF class extends Dataset for structured (regular grid) NetCDF files. It wraps GDAL's Multidimensional API to provide variable access, time dimension handling, and CF-compliant metadata.

Object model — container vs. variable#

read_file (and from_bytes / open_mfdataset) return a Container — a NetCDF whose band_count == 0 that describes the whole file. Pinning one variable with get_variable, variables[name], sel, or subset returns a Variable — a NetCDF with band_count >= 1 that behaves as a single raster. get_group opens a nested NetCDF-4 group as its own container.

A variable GDAL cannot expose as a raster — a 1-D array such as a bounds or coordinate series, or a string or compound array of any rank — comes back from get_variable and variables[name] as a LabeledArray instead: its values, dims and shape, plus the name, unit, no_data_value, scale, offset and attributes it declares. It has no raster operations, so crop_variable and the other raster-only methods refuse it by name.

Hold "Ctrl" to enable pan & zoom
flowchart LR
    F[(".nc file · bytes · many files")]
    F -->|"read_file · from_bytes · open_mfdataset"| C
    C["Container<br/>NetCDF, band_count == 0<br/>describes the file"]
    C -->|get_group| C
    C -->|"get_variable · variables[name]<br/>sel · subset"| V
    V["Variable<br/>NetCDF, band_count >= 1<br/>one variable as a raster"]
    C -->|"get_variable · variables[name]<br/>(1-D, string, compound)"| L
    L["LabeledArray<br/>values + dims + labels<br/>no raster operations"]
    V -->|plot| G(["cleopatra glyph"])
    C -. "read_array · crop · reduce · to_crs · to_file" .-> V

NetCDF inherits Dataset's eight engines and adds three of its own — interop, varops, and selection — which back the labeled-array interop, variable-mutation, and selection facades on the class:

Hold "Ctrl" to enable pan & zoom
classDiagram
    class Dataset
    class NetCDF {
      +interop : Interop
      +varops : Variables
      +selection : Selection
      +variables
      +get_variable(name)
      +read_array(variable, chunks)
      +plot(variable, ...)
      +to_kerchunk(path)
    }
    class Container
    class Variable
    class Interop {
      +to_xarray()
    }
    class Variables {
      +add_variable()
      +remove_variable()
      +rename_variable()
      +set_variable()
    }
    class Selection {
      +crop()
      +sel()
      +isel()
      +subset()
      +reduce()
      +coarsen()
      +rolling()
      +diff()
      +cumsum()
      +shift()
      +argmin()
      +argmax()
      +idxmin()
      +idxmax()
      +weighted()
    }
    Dataset <|-- NetCDF
    NetCDF <|-- Container
    NetCDF <|-- Variable
    NetCDF *-- Interop : interop
    NetCDF *-- Variables : varops
    NetCDF *-- Selection : selection
    note for Container "band_count == 0 · describes the file"
    note for Variable "band_count >= 1 · one raster variable"

Dimension coordinates and selection#

dimension_names / dimension_sizes name a cube's axes; get_dimension_values(name) reads the coordinate values of any one of them — the vertical, ensemble or other non-spatial axis included, which previously had no native accessor:

nc = NetCDF.read_file("rhum.nc")
nc.dimension_sizes                    # {'lon': 72, 'lat': 37, 'level': 4, 'time': 12}
nc.get_dimension_values("level")      # array([1000.,  925.,  850.,  700.])

The values are the stored ones — the same array to_xarray(decode_times=False).coords reports, without needing the optional xarray extra. to_xarray() itself decodes a CF time axis to datetime64[ns], so for time the two differ. For a spatial axis that is not necessarily the raster's order: pyramids presents rasters north-up, so on a south-to-north file get_dimension_values("lat") ascends while read_array()'s row 0 is the northernmost row. Use the geotransform-derived axis (nc.transform.y_axis(nc.rows)) when you want the row centres in raster order; use this when you want to know what the file holds.

Whatever the axis, these are the values sel selects on. A CF time axis is stored as raw offsets; get_time_variable() decodes the same axis to date strings, and sel accepts either vocabulary:

var = nc.get_variable("rhum")

var.sel(level=850)                            # exact stored value
var.sel(level=900, method="nearest")          # snap to the closest level (925)
var.sel(time="2024-01-01 12:00:00")           # a full-precision label pins one step
var.sel(time="2024-01")                       # a partial label takes the whole month
var.sel(time=slice("2024-01-01", "2024-01-03"))

A label names a period, not an instant, so its precision decides how much it selects. That matters when the label comes from get_time_variable(): its default time_format is "%Y-%m-%d", so feeding one of those labels straight back keeps every step of that day. Ask for the finer format when you want one step:

nc.get_time_variable("time")[1]                       # '2024-01-01'  -> the whole day
nc.get_time_variable("time", "%Y-%m-%d %H:%M:%S")[1]  # '2024-01-01 06:00:00'  -> one step

method="nearest" snaps each requested value to the closest coordinate on its axis, so a caller can ask for "the level nearest 100 m" without knowing the axis values up front. It needs a numeric selector — a slice has no nearest value, and a date label already names a period. Read back the coordinate it chose with get_dimension_values on the result:

pinned = var.sel(level=900, method="nearest")
pinned.get_dimension_values("level")   # array([925.])

Both vocabularies reach NetCDF.plot through Selectors; method="nearest" is the extra knob for the numeric one, and applies only to the dims whose selector is numeric, so a date label on another dim stays exact in the same call.

Lazy / Dask reads#

Every NetCDF entry point has a lazy variant that keeps memory bounded on multi-GB reanalysis and climate-projection files:

Hold "Ctrl" to enable pan & zoom
flowchart TD
    A["read_file(path)"] --> Q{"chunks= given?"}
    Q -->|no| E["eager NumPy array"]
    Q -->|yes| L["lazy dask array"]
    M["open_mfdataset(paths, variable)"] --> L
    K["to_kerchunk(path)"] --> J[("JSON manifest")]
    J -->|"read_file(vsi=...)"| L
    L -->|".compute()"| E
Entry point Purpose
NetCDF.read_array(chunks=…) One file, one variable, partial reads
NetCDF.open_mfdataset(paths, variable) Many files → single stacked dask array
NetCDF.to_kerchunk(path) Emit a JSON index so downstream reads are free
NetCDF.combine_kerchunk(paths, …) Combine per-file manifests into one cube index
NetCDF.to_xarray() / .from_xarray() Round-trip interop with a labeled-array dataset
from pyramids.netcdf import NetCDF

nc = NetCDF.read_file("era5.nc")
t2m = nc.read_array(
    "t2m", chunks={"time": 24, "lat": 256, "lon": 256},
)
t2m.mean(axis=0).compute()        # monthly mean, parallel

See Lazy NetCDF for chunk-size rules, CF scale/offset unpacking, and kerchunk manifest emission.

Install: pip install 'pyramids-core[lazy]' for the core path and kerchunk manifests; pip install xarray (a peer dep, not a pyramids extra) for the to_xarray / from_xarray round-trip helpers.

Plotting#

NetCDF.plot exposes a labeled-array-style plotting API that mirrors Dataset.plot — variable=, the grouped selectors= / facet= dataclasses, curvilinear coordinates via axes=CoordinateSpec(...), loose colour kwargs (cmap, vmin, vmax, robust, center, extend, levels, norm), kind=, animate=, and chunks= (lazy). It does not inherit Dataset.plot's GeoTIFF / Sentinel kwargs (band, rgb, surface_reflectance, cutoff, percentile, overview, overview_index) — passing any of them raises TypeError. See the Plotting reference for the full surface and the Selectors / CoordinateSpec / FacetSpec dataclasses, and the Plotting NetCDF data tutorial for worked examples. Requires the [viz] extra.

pyramids.netcdf.NetCDF #

Bases: Dataset

NetCDF.

NetCDF class is a recursive data structure or self-referential object. The NetCDF class contains methods to deal with NetCDF files.

NetCDF Creation guidelines

https://acdguide.github.io/Governance/create/create-basics.html

Source code in src/pyramids/netcdf/netcdf.py
 2432
 2433
 2434
 2435
 2436
 2437
 2438
 2439
 2440
 2441
 2442
 2443
 2444
 2445
 2446
 2447
 2448
 2449
 2450
 2451
 2452
 2453
 2454
 2455
 2456
 2457
 2458
 2459
 2460
 2461
 2462
 2463
 2464
 2465
 2466
 2467
 2468
 2469
 2470
 2471
 2472
 2473
 2474
 2475
 2476
 2477
 2478
 2479
 2480
 2481
 2482
 2483
 2484
 2485
 2486
 2487
 2488
 2489
 2490
 2491
 2492
 2493
 2494
 2495
 2496
 2497
 2498
 2499
 2500
 2501
 2502
 2503
 2504
 2505
 2506
 2507
 2508
 2509
 2510
 2511
 2512
 2513
 2514
 2515
 2516
 2517
 2518
 2519
 2520
 2521
 2522
 2523
 2524
 2525
 2526
 2527
 2528
 2529
 2530
 2531
 2532
 2533
 2534
 2535
 2536
 2537
 2538
 2539
 2540
 2541
 2542
 2543
 2544
 2545
 2546
 2547
 2548
 2549
 2550
 2551
 2552
 2553
 2554
 2555
 2556
 2557
 2558
 2559
 2560
 2561
 2562
 2563
 2564
 2565
 2566
 2567
 2568
 2569
 2570
 2571
 2572
 2573
 2574
 2575
 2576
 2577
 2578
 2579
 2580
 2581
 2582
 2583
 2584
 2585
 2586
 2587
 2588
 2589
 2590
 2591
 2592
 2593
 2594
 2595
 2596
 2597
 2598
 2599
 2600
 2601
 2602
 2603
 2604
 2605
 2606
 2607
 2608
 2609
 2610
 2611
 2612
 2613
 2614
 2615
 2616
 2617
 2618
 2619
 2620
 2621
 2622
 2623
 2624
 2625
 2626
 2627
 2628
 2629
 2630
 2631
 2632
 2633
 2634
 2635
 2636
 2637
 2638
 2639
 2640
 2641
 2642
 2643
 2644
 2645
 2646
 2647
 2648
 2649
 2650
 2651
 2652
 2653
 2654
 2655
 2656
 2657
 2658
 2659
 2660
 2661
 2662
 2663
 2664
 2665
 2666
 2667
 2668
 2669
 2670
 2671
 2672
 2673
 2674
 2675
 2676
 2677
 2678
 2679
 2680
 2681
 2682
 2683
 2684
 2685
 2686
 2687
 2688
 2689
 2690
 2691
 2692
 2693
 2694
 2695
 2696
 2697
 2698
 2699
 2700
 2701
 2702
 2703
 2704
 2705
 2706
 2707
 2708
 2709
 2710
 2711
 2712
 2713
 2714
 2715
 2716
 2717
 2718
 2719
 2720
 2721
 2722
 2723
 2724
 2725
 2726
 2727
 2728
 2729
 2730
 2731
 2732
 2733
 2734
 2735
 2736
 2737
 2738
 2739
 2740
 2741
 2742
 2743
 2744
 2745
 2746
 2747
 2748
 2749
 2750
 2751
 2752
 2753
 2754
 2755
 2756
 2757
 2758
 2759
 2760
 2761
 2762
 2763
 2764
 2765
 2766
 2767
 2768
 2769
 2770
 2771
 2772
 2773
 2774
 2775
 2776
 2777
 2778
 2779
 2780
 2781
 2782
 2783
 2784
 2785
 2786
 2787
 2788
 2789
 2790
 2791
 2792
 2793
 2794
 2795
 2796
 2797
 2798
 2799
 2800
 2801
 2802
 2803
 2804
 2805
 2806
 2807
 2808
 2809
 2810
 2811
 2812
 2813
 2814
 2815
 2816
 2817
 2818
 2819
 2820
 2821
 2822
 2823
 2824
 2825
 2826
 2827
 2828
 2829
 2830
 2831
 2832
 2833
 2834
 2835
 2836
 2837
 2838
 2839
 2840
 2841
 2842
 2843
 2844
 2845
 2846
 2847
 2848
 2849
 2850
 2851
 2852
 2853
 2854
 2855
 2856
 2857
 2858
 2859
 2860
 2861
 2862
 2863
 2864
 2865
 2866
 2867
 2868
 2869
 2870
 2871
 2872
 2873
 2874
 2875
 2876
 2877
 2878
 2879
 2880
 2881
 2882
 2883
 2884
 2885
 2886
 2887
 2888
 2889
 2890
 2891
 2892
 2893
 2894
 2895
 2896
 2897
 2898
 2899
 2900
 2901
 2902
 2903
 2904
 2905
 2906
 2907
 2908
 2909
 2910
 2911
 2912
 2913
 2914
 2915
 2916
 2917
 2918
 2919
 2920
 2921
 2922
 2923
 2924
 2925
 2926
 2927
 2928
 2929
 2930
 2931
 2932
 2933
 2934
 2935
 2936
 2937
 2938
 2939
 2940
 2941
 2942
 2943
 2944
 2945
 2946
 2947
 2948
 2949
 2950
 2951
 2952
 2953
 2954
 2955
 2956
 2957
 2958
 2959
 2960
 2961
 2962
 2963
 2964
 2965
 2966
 2967
 2968
 2969
 2970
 2971
 2972
 2973
 2974
 2975
 2976
 2977
 2978
 2979
 2980
 2981
 2982
 2983
 2984
 2985
 2986
 2987
 2988
 2989
 2990
 2991
 2992
 2993
 2994
 2995
 2996
 2997
 2998
 2999
 3000
 3001
 3002
 3003
 3004
 3005
 3006
 3007
 3008
 3009
 3010
 3011
 3012
 3013
 3014
 3015
 3016
 3017
 3018
 3019
 3020
 3021
 3022
 3023
 3024
 3025
 3026
 3027
 3028
 3029
 3030
 3031
 3032
 3033
 3034
 3035
 3036
 3037
 3038
 3039
 3040
 3041
 3042
 3043
 3044
 3045
 3046
 3047
 3048
 3049
 3050
 3051
 3052
 3053
 3054
 3055
 3056
 3057
 3058
 3059
 3060
 3061
 3062
 3063
 3064
 3065
 3066
 3067
 3068
 3069
 3070
 3071
 3072
 3073
 3074
 3075
 3076
 3077
 3078
 3079
 3080
 3081
 3082
 3083
 3084
 3085
 3086
 3087
 3088
 3089
 3090
 3091
 3092
 3093
 3094
 3095
 3096
 3097
 3098
 3099
 3100
 3101
 3102
 3103
 3104
 3105
 3106
 3107
 3108
 3109
 3110
 3111
 3112
 3113
 3114
 3115
 3116
 3117
 3118
 3119
 3120
 3121
 3122
 3123
 3124
 3125
 3126
 3127
 3128
 3129
 3130
 3131
 3132
 3133
 3134
 3135
 3136
 3137
 3138
 3139
 3140
 3141
 3142
 3143
 3144
 3145
 3146
 3147
 3148
 3149
 3150
 3151
 3152
 3153
 3154
 3155
 3156
 3157
 3158
 3159
 3160
 3161
 3162
 3163
 3164
 3165
 3166
 3167
 3168
 3169
 3170
 3171
 3172
 3173
 3174
 3175
 3176
 3177
 3178
 3179
 3180
 3181
 3182
 3183
 3184
 3185
 3186
 3187
 3188
 3189
 3190
 3191
 3192
 3193
 3194
 3195
 3196
 3197
 3198
 3199
 3200
 3201
 3202
 3203
 3204
 3205
 3206
 3207
 3208
 3209
 3210
 3211
 3212
 3213
 3214
 3215
 3216
 3217
 3218
 3219
 3220
 3221
 3222
 3223
 3224
 3225
 3226
 3227
 3228
 3229
 3230
 3231
 3232
 3233
 3234
 3235
 3236
 3237
 3238
 3239
 3240
 3241
 3242
 3243
 3244
 3245
 3246
 3247
 3248
 3249
 3250
 3251
 3252
 3253
 3254
 3255
 3256
 3257
 3258
 3259
 3260
 3261
 3262
 3263
 3264
 3265
 3266
 3267
 3268
 3269
 3270
 3271
 3272
 3273
 3274
 3275
 3276
 3277
 3278
 3279
 3280
 3281
 3282
 3283
 3284
 3285
 3286
 3287
 3288
 3289
 3290
 3291
 3292
 3293
 3294
 3295
 3296
 3297
 3298
 3299
 3300
 3301
 3302
 3303
 3304
 3305
 3306
 3307
 3308
 3309
 3310
 3311
 3312
 3313
 3314
 3315
 3316
 3317
 3318
 3319
 3320
 3321
 3322
 3323
 3324
 3325
 3326
 3327
 3328
 3329
 3330
 3331
 3332
 3333
 3334
 3335
 3336
 3337
 3338
 3339
 3340
 3341
 3342
 3343
 3344
 3345
 3346
 3347
 3348
 3349
 3350
 3351
 3352
 3353
 3354
 3355
 3356
 3357
 3358
 3359
 3360
 3361
 3362
 3363
 3364
 3365
 3366
 3367
 3368
 3369
 3370
 3371
 3372
 3373
 3374
 3375
 3376
 3377
 3378
 3379
 3380
 3381
 3382
 3383
 3384
 3385
 3386
 3387
 3388
 3389
 3390
 3391
 3392
 3393
 3394
 3395
 3396
 3397
 3398
 3399
 3400
 3401
 3402
 3403
 3404
 3405
 3406
 3407
 3408
 3409
 3410
 3411
 3412
 3413
 3414
 3415
 3416
 3417
 3418
 3419
 3420
 3421
 3422
 3423
 3424
 3425
 3426
 3427
 3428
 3429
 3430
 3431
 3432
 3433
 3434
 3435
 3436
 3437
 3438
 3439
 3440
 3441
 3442
 3443
 3444
 3445
 3446
 3447
 3448
 3449
 3450
 3451
 3452
 3453
 3454
 3455
 3456
 3457
 3458
 3459
 3460
 3461
 3462
 3463
 3464
 3465
 3466
 3467
 3468
 3469
 3470
 3471
 3472
 3473
 3474
 3475
 3476
 3477
 3478
 3479
 3480
 3481
 3482
 3483
 3484
 3485
 3486
 3487
 3488
 3489
 3490
 3491
 3492
 3493
 3494
 3495
 3496
 3497
 3498
 3499
 3500
 3501
 3502
 3503
 3504
 3505
 3506
 3507
 3508
 3509
 3510
 3511
 3512
 3513
 3514
 3515
 3516
 3517
 3518
 3519
 3520
 3521
 3522
 3523
 3524
 3525
 3526
 3527
 3528
 3529
 3530
 3531
 3532
 3533
 3534
 3535
 3536
 3537
 3538
 3539
 3540
 3541
 3542
 3543
 3544
 3545
 3546
 3547
 3548
 3549
 3550
 3551
 3552
 3553
 3554
 3555
 3556
 3557
 3558
 3559
 3560
 3561
 3562
 3563
 3564
 3565
 3566
 3567
 3568
 3569
 3570
 3571
 3572
 3573
 3574
 3575
 3576
 3577
 3578
 3579
 3580
 3581
 3582
 3583
 3584
 3585
 3586
 3587
 3588
 3589
 3590
 3591
 3592
 3593
 3594
 3595
 3596
 3597
 3598
 3599
 3600
 3601
 3602
 3603
 3604
 3605
 3606
 3607
 3608
 3609
 3610
 3611
 3612
 3613
 3614
 3615
 3616
 3617
 3618
 3619
 3620
 3621
 3622
 3623
 3624
 3625
 3626
 3627
 3628
 3629
 3630
 3631
 3632
 3633
 3634
 3635
 3636
 3637
 3638
 3639
 3640
 3641
 3642
 3643
 3644
 3645
 3646
 3647
 3648
 3649
 3650
 3651
 3652
 3653
 3654
 3655
 3656
 3657
 3658
 3659
 3660
 3661
 3662
 3663
 3664
 3665
 3666
 3667
 3668
 3669
 3670
 3671
 3672
 3673
 3674
 3675
 3676
 3677
 3678
 3679
 3680
 3681
 3682
 3683
 3684
 3685
 3686
 3687
 3688
 3689
 3690
 3691
 3692
 3693
 3694
 3695
 3696
 3697
 3698
 3699
 3700
 3701
 3702
 3703
 3704
 3705
 3706
 3707
 3708
 3709
 3710
 3711
 3712
 3713
 3714
 3715
 3716
 3717
 3718
 3719
 3720
 3721
 3722
 3723
 3724
 3725
 3726
 3727
 3728
 3729
 3730
 3731
 3732
 3733
 3734
 3735
 3736
 3737
 3738
 3739
 3740
 3741
 3742
 3743
 3744
 3745
 3746
 3747
 3748
 3749
 3750
 3751
 3752
 3753
 3754
 3755
 3756
 3757
 3758
 3759
 3760
 3761
 3762
 3763
 3764
 3765
 3766
 3767
 3768
 3769
 3770
 3771
 3772
 3773
 3774
 3775
 3776
 3777
 3778
 3779
 3780
 3781
 3782
 3783
 3784
 3785
 3786
 3787
 3788
 3789
 3790
 3791
 3792
 3793
 3794
 3795
 3796
 3797
 3798
 3799
 3800
 3801
 3802
 3803
 3804
 3805
 3806
 3807
 3808
 3809
 3810
 3811
 3812
 3813
 3814
 3815
 3816
 3817
 3818
 3819
 3820
 3821
 3822
 3823
 3824
 3825
 3826
 3827
 3828
 3829
 3830
 3831
 3832
 3833
 3834
 3835
 3836
 3837
 3838
 3839
 3840
 3841
 3842
 3843
 3844
 3845
 3846
 3847
 3848
 3849
 3850
 3851
 3852
 3853
 3854
 3855
 3856
 3857
 3858
 3859
 3860
 3861
 3862
 3863
 3864
 3865
 3866
 3867
 3868
 3869
 3870
 3871
 3872
 3873
 3874
 3875
 3876
 3877
 3878
 3879
 3880
 3881
 3882
 3883
 3884
 3885
 3886
 3887
 3888
 3889
 3890
 3891
 3892
 3893
 3894
 3895
 3896
 3897
 3898
 3899
 3900
 3901
 3902
 3903
 3904
 3905
 3906
 3907
 3908
 3909
 3910
 3911
 3912
 3913
 3914
 3915
 3916
 3917
 3918
 3919
 3920
 3921
 3922
 3923
 3924
 3925
 3926
 3927
 3928
 3929
 3930
 3931
 3932
 3933
 3934
 3935
 3936
 3937
 3938
 3939
 3940
 3941
 3942
 3943
 3944
 3945
 3946
 3947
 3948
 3949
 3950
 3951
 3952
 3953
 3954
 3955
 3956
 3957
 3958
 3959
 3960
 3961
 3962
 3963
 3964
 3965
 3966
 3967
 3968
 3969
 3970
 3971
 3972
 3973
 3974
 3975
 3976
 3977
 3978
 3979
 3980
 3981
 3982
 3983
 3984
 3985
 3986
 3987
 3988
 3989
 3990
 3991
 3992
 3993
 3994
 3995
 3996
 3997
 3998
 3999
 4000
 4001
 4002
 4003
 4004
 4005
 4006
 4007
 4008
 4009
 4010
 4011
 4012
 4013
 4014
 4015
 4016
 4017
 4018
 4019
 4020
 4021
 4022
 4023
 4024
 4025
 4026
 4027
 4028
 4029
 4030
 4031
 4032
 4033
 4034
 4035
 4036
 4037
 4038
 4039
 4040
 4041
 4042
 4043
 4044
 4045
 4046
 4047
 4048
 4049
 4050
 4051
 4052
 4053
 4054
 4055
 4056
 4057
 4058
 4059
 4060
 4061
 4062
 4063
 4064
 4065
 4066
 4067
 4068
 4069
 4070
 4071
 4072
 4073
 4074
 4075
 4076
 4077
 4078
 4079
 4080
 4081
 4082
 4083
 4084
 4085
 4086
 4087
 4088
 4089
 4090
 4091
 4092
 4093
 4094
 4095
 4096
 4097
 4098
 4099
 4100
 4101
 4102
 4103
 4104
 4105
 4106
 4107
 4108
 4109
 4110
 4111
 4112
 4113
 4114
 4115
 4116
 4117
 4118
 4119
 4120
 4121
 4122
 4123
 4124
 4125
 4126
 4127
 4128
 4129
 4130
 4131
 4132
 4133
 4134
 4135
 4136
 4137
 4138
 4139
 4140
 4141
 4142
 4143
 4144
 4145
 4146
 4147
 4148
 4149
 4150
 4151
 4152
 4153
 4154
 4155
 4156
 4157
 4158
 4159
 4160
 4161
 4162
 4163
 4164
 4165
 4166
 4167
 4168
 4169
 4170
 4171
 4172
 4173
 4174
 4175
 4176
 4177
 4178
 4179
 4180
 4181
 4182
 4183
 4184
 4185
 4186
 4187
 4188
 4189
 4190
 4191
 4192
 4193
 4194
 4195
 4196
 4197
 4198
 4199
 4200
 4201
 4202
 4203
 4204
 4205
 4206
 4207
 4208
 4209
 4210
 4211
 4212
 4213
 4214
 4215
 4216
 4217
 4218
 4219
 4220
 4221
 4222
 4223
 4224
 4225
 4226
 4227
 4228
 4229
 4230
 4231
 4232
 4233
 4234
 4235
 4236
 4237
 4238
 4239
 4240
 4241
 4242
 4243
 4244
 4245
 4246
 4247
 4248
 4249
 4250
 4251
 4252
 4253
 4254
 4255
 4256
 4257
 4258
 4259
 4260
 4261
 4262
 4263
 4264
 4265
 4266
 4267
 4268
 4269
 4270
 4271
 4272
 4273
 4274
 4275
 4276
 4277
 4278
 4279
 4280
 4281
 4282
 4283
 4284
 4285
 4286
 4287
 4288
 4289
 4290
 4291
 4292
 4293
 4294
 4295
 4296
 4297
 4298
 4299
 4300
 4301
 4302
 4303
 4304
 4305
 4306
 4307
 4308
 4309
 4310
 4311
 4312
 4313
 4314
 4315
 4316
 4317
 4318
 4319
 4320
 4321
 4322
 4323
 4324
 4325
 4326
 4327
 4328
 4329
 4330
 4331
 4332
 4333
 4334
 4335
 4336
 4337
 4338
 4339
 4340
 4341
 4342
 4343
 4344
 4345
 4346
 4347
 4348
 4349
 4350
 4351
 4352
 4353
 4354
 4355
 4356
 4357
 4358
 4359
 4360
 4361
 4362
 4363
 4364
 4365
 4366
 4367
 4368
 4369
 4370
 4371
 4372
 4373
 4374
 4375
 4376
 4377
 4378
 4379
 4380
 4381
 4382
 4383
 4384
 4385
 4386
 4387
 4388
 4389
 4390
 4391
 4392
 4393
 4394
 4395
 4396
 4397
 4398
 4399
 4400
 4401
 4402
 4403
 4404
 4405
 4406
 4407
 4408
 4409
 4410
 4411
 4412
 4413
 4414
 4415
 4416
 4417
 4418
 4419
 4420
 4421
 4422
 4423
 4424
 4425
 4426
 4427
 4428
 4429
 4430
 4431
 4432
 4433
 4434
 4435
 4436
 4437
 4438
 4439
 4440
 4441
 4442
 4443
 4444
 4445
 4446
 4447
 4448
 4449
 4450
 4451
 4452
 4453
 4454
 4455
 4456
 4457
 4458
 4459
 4460
 4461
 4462
 4463
 4464
 4465
 4466
 4467
 4468
 4469
 4470
 4471
 4472
 4473
 4474
 4475
 4476
 4477
 4478
 4479
 4480
 4481
 4482
 4483
 4484
 4485
 4486
 4487
 4488
 4489
 4490
 4491
 4492
 4493
 4494
 4495
 4496
 4497
 4498
 4499
 4500
 4501
 4502
 4503
 4504
 4505
 4506
 4507
 4508
 4509
 4510
 4511
 4512
 4513
 4514
 4515
 4516
 4517
 4518
 4519
 4520
 4521
 4522
 4523
 4524
 4525
 4526
 4527
 4528
 4529
 4530
 4531
 4532
 4533
 4534
 4535
 4536
 4537
 4538
 4539
 4540
 4541
 4542
 4543
 4544
 4545
 4546
 4547
 4548
 4549
 4550
 4551
 4552
 4553
 4554
 4555
 4556
 4557
 4558
 4559
 4560
 4561
 4562
 4563
 4564
 4565
 4566
 4567
 4568
 4569
 4570
 4571
 4572
 4573
 4574
 4575
 4576
 4577
 4578
 4579
 4580
 4581
 4582
 4583
 4584
 4585
 4586
 4587
 4588
 4589
 4590
 4591
 4592
 4593
 4594
 4595
 4596
 4597
 4598
 4599
 4600
 4601
 4602
 4603
 4604
 4605
 4606
 4607
 4608
 4609
 4610
 4611
 4612
 4613
 4614
 4615
 4616
 4617
 4618
 4619
 4620
 4621
 4622
 4623
 4624
 4625
 4626
 4627
 4628
 4629
 4630
 4631
 4632
 4633
 4634
 4635
 4636
 4637
 4638
 4639
 4640
 4641
 4642
 4643
 4644
 4645
 4646
 4647
 4648
 4649
 4650
 4651
 4652
 4653
 4654
 4655
 4656
 4657
 4658
 4659
 4660
 4661
 4662
 4663
 4664
 4665
 4666
 4667
 4668
 4669
 4670
 4671
 4672
 4673
 4674
 4675
 4676
 4677
 4678
 4679
 4680
 4681
 4682
 4683
 4684
 4685
 4686
 4687
 4688
 4689
 4690
 4691
 4692
 4693
 4694
 4695
 4696
 4697
 4698
 4699
 4700
 4701
 4702
 4703
 4704
 4705
 4706
 4707
 4708
 4709
 4710
 4711
 4712
 4713
 4714
 4715
 4716
 4717
 4718
 4719
 4720
 4721
 4722
 4723
 4724
 4725
 4726
 4727
 4728
 4729
 4730
 4731
 4732
 4733
 4734
 4735
 4736
 4737
 4738
 4739
 4740
 4741
 4742
 4743
 4744
 4745
 4746
 4747
 4748
 4749
 4750
 4751
 4752
 4753
 4754
 4755
 4756
 4757
 4758
 4759
 4760
 4761
 4762
 4763
 4764
 4765
 4766
 4767
 4768
 4769
 4770
 4771
 4772
 4773
 4774
 4775
 4776
 4777
 4778
 4779
 4780
 4781
 4782
 4783
 4784
 4785
 4786
 4787
 4788
 4789
 4790
 4791
 4792
 4793
 4794
 4795
 4796
 4797
 4798
 4799
 4800
 4801
 4802
 4803
 4804
 4805
 4806
 4807
 4808
 4809
 4810
 4811
 4812
 4813
 4814
 4815
 4816
 4817
 4818
 4819
 4820
 4821
 4822
 4823
 4824
 4825
 4826
 4827
 4828
 4829
 4830
 4831
 4832
 4833
 4834
 4835
 4836
 4837
 4838
 4839
 4840
 4841
 4842
 4843
 4844
 4845
 4846
 4847
 4848
 4849
 4850
 4851
 4852
 4853
 4854
 4855
 4856
 4857
 4858
 4859
 4860
 4861
 4862
 4863
 4864
 4865
 4866
 4867
 4868
 4869
 4870
 4871
 4872
 4873
 4874
 4875
 4876
 4877
 4878
 4879
 4880
 4881
 4882
 4883
 4884
 4885
 4886
 4887
 4888
 4889
 4890
 4891
 4892
 4893
 4894
 4895
 4896
 4897
 4898
 4899
 4900
 4901
 4902
 4903
 4904
 4905
 4906
 4907
 4908
 4909
 4910
 4911
 4912
 4913
 4914
 4915
 4916
 4917
 4918
 4919
 4920
 4921
 4922
 4923
 4924
 4925
 4926
 4927
 4928
 4929
 4930
 4931
 4932
 4933
 4934
 4935
 4936
 4937
 4938
 4939
 4940
 4941
 4942
 4943
 4944
 4945
 4946
 4947
 4948
 4949
 4950
 4951
 4952
 4953
 4954
 4955
 4956
 4957
 4958
 4959
 4960
 4961
 4962
 4963
 4964
 4965
 4966
 4967
 4968
 4969
 4970
 4971
 4972
 4973
 4974
 4975
 4976
 4977
 4978
 4979
 4980
 4981
 4982
 4983
 4984
 4985
 4986
 4987
 4988
 4989
 4990
 4991
 4992
 4993
 4994
 4995
 4996
 4997
 4998
 4999
 5000
 5001
 5002
 5003
 5004
 5005
 5006
 5007
 5008
 5009
 5010
 5011
 5012
 5013
 5014
 5015
 5016
 5017
 5018
 5019
 5020
 5021
 5022
 5023
 5024
 5025
 5026
 5027
 5028
 5029
 5030
 5031
 5032
 5033
 5034
 5035
 5036
 5037
 5038
 5039
 5040
 5041
 5042
 5043
 5044
 5045
 5046
 5047
 5048
 5049
 5050
 5051
 5052
 5053
 5054
 5055
 5056
 5057
 5058
 5059
 5060
 5061
 5062
 5063
 5064
 5065
 5066
 5067
 5068
 5069
 5070
 5071
 5072
 5073
 5074
 5075
 5076
 5077
 5078
 5079
 5080
 5081
 5082
 5083
 5084
 5085
 5086
 5087
 5088
 5089
 5090
 5091
 5092
 5093
 5094
 5095
 5096
 5097
 5098
 5099
 5100
 5101
 5102
 5103
 5104
 5105
 5106
 5107
 5108
 5109
 5110
 5111
 5112
 5113
 5114
 5115
 5116
 5117
 5118
 5119
 5120
 5121
 5122
 5123
 5124
 5125
 5126
 5127
 5128
 5129
 5130
 5131
 5132
 5133
 5134
 5135
 5136
 5137
 5138
 5139
 5140
 5141
 5142
 5143
 5144
 5145
 5146
 5147
 5148
 5149
 5150
 5151
 5152
 5153
 5154
 5155
 5156
 5157
 5158
 5159
 5160
 5161
 5162
 5163
 5164
 5165
 5166
 5167
 5168
 5169
 5170
 5171
 5172
 5173
 5174
 5175
 5176
 5177
 5178
 5179
 5180
 5181
 5182
 5183
 5184
 5185
 5186
 5187
 5188
 5189
 5190
 5191
 5192
 5193
 5194
 5195
 5196
 5197
 5198
 5199
 5200
 5201
 5202
 5203
 5204
 5205
 5206
 5207
 5208
 5209
 5210
 5211
 5212
 5213
 5214
 5215
 5216
 5217
 5218
 5219
 5220
 5221
 5222
 5223
 5224
 5225
 5226
 5227
 5228
 5229
 5230
 5231
 5232
 5233
 5234
 5235
 5236
 5237
 5238
 5239
 5240
 5241
 5242
 5243
 5244
 5245
 5246
 5247
 5248
 5249
 5250
 5251
 5252
 5253
 5254
 5255
 5256
 5257
 5258
 5259
 5260
 5261
 5262
 5263
 5264
 5265
 5266
 5267
 5268
 5269
 5270
 5271
 5272
 5273
 5274
 5275
 5276
 5277
 5278
 5279
 5280
 5281
 5282
 5283
 5284
 5285
 5286
 5287
 5288
 5289
 5290
 5291
 5292
 5293
 5294
 5295
 5296
 5297
 5298
 5299
 5300
 5301
 5302
 5303
 5304
 5305
 5306
 5307
 5308
 5309
 5310
 5311
 5312
 5313
 5314
 5315
 5316
 5317
 5318
 5319
 5320
 5321
 5322
 5323
 5324
 5325
 5326
 5327
 5328
 5329
 5330
 5331
 5332
 5333
 5334
 5335
 5336
 5337
 5338
 5339
 5340
 5341
 5342
 5343
 5344
 5345
 5346
 5347
 5348
 5349
 5350
 5351
 5352
 5353
 5354
 5355
 5356
 5357
 5358
 5359
 5360
 5361
 5362
 5363
 5364
 5365
 5366
 5367
 5368
 5369
 5370
 5371
 5372
 5373
 5374
 5375
 5376
 5377
 5378
 5379
 5380
 5381
 5382
 5383
 5384
 5385
 5386
 5387
 5388
 5389
 5390
 5391
 5392
 5393
 5394
 5395
 5396
 5397
 5398
 5399
 5400
 5401
 5402
 5403
 5404
 5405
 5406
 5407
 5408
 5409
 5410
 5411
 5412
 5413
 5414
 5415
 5416
 5417
 5418
 5419
 5420
 5421
 5422
 5423
 5424
 5425
 5426
 5427
 5428
 5429
 5430
 5431
 5432
 5433
 5434
 5435
 5436
 5437
 5438
 5439
 5440
 5441
 5442
 5443
 5444
 5445
 5446
 5447
 5448
 5449
 5450
 5451
 5452
 5453
 5454
 5455
 5456
 5457
 5458
 5459
 5460
 5461
 5462
 5463
 5464
 5465
 5466
 5467
 5468
 5469
 5470
 5471
 5472
 5473
 5474
 5475
 5476
 5477
 5478
 5479
 5480
 5481
 5482
 5483
 5484
 5485
 5486
 5487
 5488
 5489
 5490
 5491
 5492
 5493
 5494
 5495
 5496
 5497
 5498
 5499
 5500
 5501
 5502
 5503
 5504
 5505
 5506
 5507
 5508
 5509
 5510
 5511
 5512
 5513
 5514
 5515
 5516
 5517
 5518
 5519
 5520
 5521
 5522
 5523
 5524
 5525
 5526
 5527
 5528
 5529
 5530
 5531
 5532
 5533
 5534
 5535
 5536
 5537
 5538
 5539
 5540
 5541
 5542
 5543
 5544
 5545
 5546
 5547
 5548
 5549
 5550
 5551
 5552
 5553
 5554
 5555
 5556
 5557
 5558
 5559
 5560
 5561
 5562
 5563
 5564
 5565
 5566
 5567
 5568
 5569
 5570
 5571
 5572
 5573
 5574
 5575
 5576
 5577
 5578
 5579
 5580
 5581
 5582
 5583
 5584
 5585
 5586
 5587
 5588
 5589
 5590
 5591
 5592
 5593
 5594
 5595
 5596
 5597
 5598
 5599
 5600
 5601
 5602
 5603
 5604
 5605
 5606
 5607
 5608
 5609
 5610
 5611
 5612
 5613
 5614
 5615
 5616
 5617
 5618
 5619
 5620
 5621
 5622
 5623
 5624
 5625
 5626
 5627
 5628
 5629
 5630
 5631
 5632
 5633
 5634
 5635
 5636
 5637
 5638
 5639
 5640
 5641
 5642
 5643
 5644
 5645
 5646
 5647
 5648
 5649
 5650
 5651
 5652
 5653
 5654
 5655
 5656
 5657
 5658
 5659
 5660
 5661
 5662
 5663
 5664
 5665
 5666
 5667
 5668
 5669
 5670
 5671
 5672
 5673
 5674
 5675
 5676
 5677
 5678
 5679
 5680
 5681
 5682
 5683
 5684
 5685
 5686
 5687
 5688
 5689
 5690
 5691
 5692
 5693
 5694
 5695
 5696
 5697
 5698
 5699
 5700
 5701
 5702
 5703
 5704
 5705
 5706
 5707
 5708
 5709
 5710
 5711
 5712
 5713
 5714
 5715
 5716
 5717
 5718
 5719
 5720
 5721
 5722
 5723
 5724
 5725
 5726
 5727
 5728
 5729
 5730
 5731
 5732
 5733
 5734
 5735
 5736
 5737
 5738
 5739
 5740
 5741
 5742
 5743
 5744
 5745
 5746
 5747
 5748
 5749
 5750
 5751
 5752
 5753
 5754
 5755
 5756
 5757
 5758
 5759
 5760
 5761
 5762
 5763
 5764
 5765
 5766
 5767
 5768
 5769
 5770
 5771
 5772
 5773
 5774
 5775
 5776
 5777
 5778
 5779
 5780
 5781
 5782
 5783
 5784
 5785
 5786
 5787
 5788
 5789
 5790
 5791
 5792
 5793
 5794
 5795
 5796
 5797
 5798
 5799
 5800
 5801
 5802
 5803
 5804
 5805
 5806
 5807
 5808
 5809
 5810
 5811
 5812
 5813
 5814
 5815
 5816
 5817
 5818
 5819
 5820
 5821
 5822
 5823
 5824
 5825
 5826
 5827
 5828
 5829
 5830
 5831
 5832
 5833
 5834
 5835
 5836
 5837
 5838
 5839
 5840
 5841
 5842
 5843
 5844
 5845
 5846
 5847
 5848
 5849
 5850
 5851
 5852
 5853
 5854
 5855
 5856
 5857
 5858
 5859
 5860
 5861
 5862
 5863
 5864
 5865
 5866
 5867
 5868
 5869
 5870
 5871
 5872
 5873
 5874
 5875
 5876
 5877
 5878
 5879
 5880
 5881
 5882
 5883
 5884
 5885
 5886
 5887
 5888
 5889
 5890
 5891
 5892
 5893
 5894
 5895
 5896
 5897
 5898
 5899
 5900
 5901
 5902
 5903
 5904
 5905
 5906
 5907
 5908
 5909
 5910
 5911
 5912
 5913
 5914
 5915
 5916
 5917
 5918
 5919
 5920
 5921
 5922
 5923
 5924
 5925
 5926
 5927
 5928
 5929
 5930
 5931
 5932
 5933
 5934
 5935
 5936
 5937
 5938
 5939
 5940
 5941
 5942
 5943
 5944
 5945
 5946
 5947
 5948
 5949
 5950
 5951
 5952
 5953
 5954
 5955
 5956
 5957
 5958
 5959
 5960
 5961
 5962
 5963
 5964
 5965
 5966
 5967
 5968
 5969
 5970
 5971
 5972
 5973
 5974
 5975
 5976
 5977
 5978
 5979
 5980
 5981
 5982
 5983
 5984
 5985
 5986
 5987
 5988
 5989
 5990
 5991
 5992
 5993
 5994
 5995
 5996
 5997
 5998
 5999
 6000
 6001
 6002
 6003
 6004
 6005
 6006
 6007
 6008
 6009
 6010
 6011
 6012
 6013
 6014
 6015
 6016
 6017
 6018
 6019
 6020
 6021
 6022
 6023
 6024
 6025
 6026
 6027
 6028
 6029
 6030
 6031
 6032
 6033
 6034
 6035
 6036
 6037
 6038
 6039
 6040
 6041
 6042
 6043
 6044
 6045
 6046
 6047
 6048
 6049
 6050
 6051
 6052
 6053
 6054
 6055
 6056
 6057
 6058
 6059
 6060
 6061
 6062
 6063
 6064
 6065
 6066
 6067
 6068
 6069
 6070
 6071
 6072
 6073
 6074
 6075
 6076
 6077
 6078
 6079
 6080
 6081
 6082
 6083
 6084
 6085
 6086
 6087
 6088
 6089
 6090
 6091
 6092
 6093
 6094
 6095
 6096
 6097
 6098
 6099
 6100
 6101
 6102
 6103
 6104
 6105
 6106
 6107
 6108
 6109
 6110
 6111
 6112
 6113
 6114
 6115
 6116
 6117
 6118
 6119
 6120
 6121
 6122
 6123
 6124
 6125
 6126
 6127
 6128
 6129
 6130
 6131
 6132
 6133
 6134
 6135
 6136
 6137
 6138
 6139
 6140
 6141
 6142
 6143
 6144
 6145
 6146
 6147
 6148
 6149
 6150
 6151
 6152
 6153
 6154
 6155
 6156
 6157
 6158
 6159
 6160
 6161
 6162
 6163
 6164
 6165
 6166
 6167
 6168
 6169
 6170
 6171
 6172
 6173
 6174
 6175
 6176
 6177
 6178
 6179
 6180
 6181
 6182
 6183
 6184
 6185
 6186
 6187
 6188
 6189
 6190
 6191
 6192
 6193
 6194
 6195
 6196
 6197
 6198
 6199
 6200
 6201
 6202
 6203
 6204
 6205
 6206
 6207
 6208
 6209
 6210
 6211
 6212
 6213
 6214
 6215
 6216
 6217
 6218
 6219
 6220
 6221
 6222
 6223
 6224
 6225
 6226
 6227
 6228
 6229
 6230
 6231
 6232
 6233
 6234
 6235
 6236
 6237
 6238
 6239
 6240
 6241
 6242
 6243
 6244
 6245
 6246
 6247
 6248
 6249
 6250
 6251
 6252
 6253
 6254
 6255
 6256
 6257
 6258
 6259
 6260
 6261
 6262
 6263
 6264
 6265
 6266
 6267
 6268
 6269
 6270
 6271
 6272
 6273
 6274
 6275
 6276
 6277
 6278
 6279
 6280
 6281
 6282
 6283
 6284
 6285
 6286
 6287
 6288
 6289
 6290
 6291
 6292
 6293
 6294
 6295
 6296
 6297
 6298
 6299
 6300
 6301
 6302
 6303
 6304
 6305
 6306
 6307
 6308
 6309
 6310
 6311
 6312
 6313
 6314
 6315
 6316
 6317
 6318
 6319
 6320
 6321
 6322
 6323
 6324
 6325
 6326
 6327
 6328
 6329
 6330
 6331
 6332
 6333
 6334
 6335
 6336
 6337
 6338
 6339
 6340
 6341
 6342
 6343
 6344
 6345
 6346
 6347
 6348
 6349
 6350
 6351
 6352
 6353
 6354
 6355
 6356
 6357
 6358
 6359
 6360
 6361
 6362
 6363
 6364
 6365
 6366
 6367
 6368
 6369
 6370
 6371
 6372
 6373
 6374
 6375
 6376
 6377
 6378
 6379
 6380
 6381
 6382
 6383
 6384
 6385
 6386
 6387
 6388
 6389
 6390
 6391
 6392
 6393
 6394
 6395
 6396
 6397
 6398
 6399
 6400
 6401
 6402
 6403
 6404
 6405
 6406
 6407
 6408
 6409
 6410
 6411
 6412
 6413
 6414
 6415
 6416
 6417
 6418
 6419
 6420
 6421
 6422
 6423
 6424
 6425
 6426
 6427
 6428
 6429
 6430
 6431
 6432
 6433
 6434
 6435
 6436
 6437
 6438
 6439
 6440
 6441
 6442
 6443
 6444
 6445
 6446
 6447
 6448
 6449
 6450
 6451
 6452
 6453
 6454
 6455
 6456
 6457
 6458
 6459
 6460
 6461
 6462
 6463
 6464
 6465
 6466
 6467
 6468
 6469
 6470
 6471
 6472
 6473
 6474
 6475
 6476
 6477
 6478
 6479
 6480
 6481
 6482
 6483
 6484
 6485
 6486
 6487
 6488
 6489
 6490
 6491
 6492
 6493
 6494
 6495
 6496
 6497
 6498
 6499
 6500
 6501
 6502
 6503
 6504
 6505
 6506
 6507
 6508
 6509
 6510
 6511
 6512
 6513
 6514
 6515
 6516
 6517
 6518
 6519
 6520
 6521
 6522
 6523
 6524
 6525
 6526
 6527
 6528
 6529
 6530
 6531
 6532
 6533
 6534
 6535
 6536
 6537
 6538
 6539
 6540
 6541
 6542
 6543
 6544
 6545
 6546
 6547
 6548
 6549
 6550
 6551
 6552
 6553
 6554
 6555
 6556
 6557
 6558
 6559
 6560
 6561
 6562
 6563
 6564
 6565
 6566
 6567
 6568
 6569
 6570
 6571
 6572
 6573
 6574
 6575
 6576
 6577
 6578
 6579
 6580
 6581
 6582
 6583
 6584
 6585
 6586
 6587
 6588
 6589
 6590
 6591
 6592
 6593
 6594
 6595
 6596
 6597
 6598
 6599
 6600
 6601
 6602
 6603
 6604
 6605
 6606
 6607
 6608
 6609
 6610
 6611
 6612
 6613
 6614
 6615
 6616
 6617
 6618
 6619
 6620
 6621
 6622
 6623
 6624
 6625
 6626
 6627
 6628
 6629
 6630
 6631
 6632
 6633
 6634
 6635
 6636
 6637
 6638
 6639
 6640
 6641
 6642
 6643
 6644
 6645
 6646
 6647
 6648
 6649
 6650
 6651
 6652
 6653
 6654
 6655
 6656
 6657
 6658
 6659
 6660
 6661
 6662
 6663
 6664
 6665
 6666
 6667
 6668
 6669
 6670
 6671
 6672
 6673
 6674
 6675
 6676
 6677
 6678
 6679
 6680
 6681
 6682
 6683
 6684
 6685
 6686
 6687
 6688
 6689
 6690
 6691
 6692
 6693
 6694
 6695
 6696
 6697
 6698
 6699
 6700
 6701
 6702
 6703
 6704
 6705
 6706
 6707
 6708
 6709
 6710
 6711
 6712
 6713
 6714
 6715
 6716
 6717
 6718
 6719
 6720
 6721
 6722
 6723
 6724
 6725
 6726
 6727
 6728
 6729
 6730
 6731
 6732
 6733
 6734
 6735
 6736
 6737
 6738
 6739
 6740
 6741
 6742
 6743
 6744
 6745
 6746
 6747
 6748
 6749
 6750
 6751
 6752
 6753
 6754
 6755
 6756
 6757
 6758
 6759
 6760
 6761
 6762
 6763
 6764
 6765
 6766
 6767
 6768
 6769
 6770
 6771
 6772
 6773
 6774
 6775
 6776
 6777
 6778
 6779
 6780
 6781
 6782
 6783
 6784
 6785
 6786
 6787
 6788
 6789
 6790
 6791
 6792
 6793
 6794
 6795
 6796
 6797
 6798
 6799
 6800
 6801
 6802
 6803
 6804
 6805
 6806
 6807
 6808
 6809
 6810
 6811
 6812
 6813
 6814
 6815
 6816
 6817
 6818
 6819
 6820
 6821
 6822
 6823
 6824
 6825
 6826
 6827
 6828
 6829
 6830
 6831
 6832
 6833
 6834
 6835
 6836
 6837
 6838
 6839
 6840
 6841
 6842
 6843
 6844
 6845
 6846
 6847
 6848
 6849
 6850
 6851
 6852
 6853
 6854
 6855
 6856
 6857
 6858
 6859
 6860
 6861
 6862
 6863
 6864
 6865
 6866
 6867
 6868
 6869
 6870
 6871
 6872
 6873
 6874
 6875
 6876
 6877
 6878
 6879
 6880
 6881
 6882
 6883
 6884
 6885
 6886
 6887
 6888
 6889
 6890
 6891
 6892
 6893
 6894
 6895
 6896
 6897
 6898
 6899
 6900
 6901
 6902
 6903
 6904
 6905
 6906
 6907
 6908
 6909
 6910
 6911
 6912
 6913
 6914
 6915
 6916
 6917
 6918
 6919
 6920
 6921
 6922
 6923
 6924
 6925
 6926
 6927
 6928
 6929
 6930
 6931
 6932
 6933
 6934
 6935
 6936
 6937
 6938
 6939
 6940
 6941
 6942
 6943
 6944
 6945
 6946
 6947
 6948
 6949
 6950
 6951
 6952
 6953
 6954
 6955
 6956
 6957
 6958
 6959
 6960
 6961
 6962
 6963
 6964
 6965
 6966
 6967
 6968
 6969
 6970
 6971
 6972
 6973
 6974
 6975
 6976
 6977
 6978
 6979
 6980
 6981
 6982
 6983
 6984
 6985
 6986
 6987
 6988
 6989
 6990
 6991
 6992
 6993
 6994
 6995
 6996
 6997
 6998
 6999
 7000
 7001
 7002
 7003
 7004
 7005
 7006
 7007
 7008
 7009
 7010
 7011
 7012
 7013
 7014
 7015
 7016
 7017
 7018
 7019
 7020
 7021
 7022
 7023
 7024
 7025
 7026
 7027
 7028
 7029
 7030
 7031
 7032
 7033
 7034
 7035
 7036
 7037
 7038
 7039
 7040
 7041
 7042
 7043
 7044
 7045
 7046
 7047
 7048
 7049
 7050
 7051
 7052
 7053
 7054
 7055
 7056
 7057
 7058
 7059
 7060
 7061
 7062
 7063
 7064
 7065
 7066
 7067
 7068
 7069
 7070
 7071
 7072
 7073
 7074
 7075
 7076
 7077
 7078
 7079
 7080
 7081
 7082
 7083
 7084
 7085
 7086
 7087
 7088
 7089
 7090
 7091
 7092
 7093
 7094
 7095
 7096
 7097
 7098
 7099
 7100
 7101
 7102
 7103
 7104
 7105
 7106
 7107
 7108
 7109
 7110
 7111
 7112
 7113
 7114
 7115
 7116
 7117
 7118
 7119
 7120
 7121
 7122
 7123
 7124
 7125
 7126
 7127
 7128
 7129
 7130
 7131
 7132
 7133
 7134
 7135
 7136
 7137
 7138
 7139
 7140
 7141
 7142
 7143
 7144
 7145
 7146
 7147
 7148
 7149
 7150
 7151
 7152
 7153
 7154
 7155
 7156
 7157
 7158
 7159
 7160
 7161
 7162
 7163
 7164
 7165
 7166
 7167
 7168
 7169
 7170
 7171
 7172
 7173
 7174
 7175
 7176
 7177
 7178
 7179
 7180
 7181
 7182
 7183
 7184
 7185
 7186
 7187
 7188
 7189
 7190
 7191
 7192
 7193
 7194
 7195
 7196
 7197
 7198
 7199
 7200
 7201
 7202
 7203
 7204
 7205
 7206
 7207
 7208
 7209
 7210
 7211
 7212
 7213
 7214
 7215
 7216
 7217
 7218
 7219
 7220
 7221
 7222
 7223
 7224
 7225
 7226
 7227
 7228
 7229
 7230
 7231
 7232
 7233
 7234
 7235
 7236
 7237
 7238
 7239
 7240
 7241
 7242
 7243
 7244
 7245
 7246
 7247
 7248
 7249
 7250
 7251
 7252
 7253
 7254
 7255
 7256
 7257
 7258
 7259
 7260
 7261
 7262
 7263
 7264
 7265
 7266
 7267
 7268
 7269
 7270
 7271
 7272
 7273
 7274
 7275
 7276
 7277
 7278
 7279
 7280
 7281
 7282
 7283
 7284
 7285
 7286
 7287
 7288
 7289
 7290
 7291
 7292
 7293
 7294
 7295
 7296
 7297
 7298
 7299
 7300
 7301
 7302
 7303
 7304
 7305
 7306
 7307
 7308
 7309
 7310
 7311
 7312
 7313
 7314
 7315
 7316
 7317
 7318
 7319
 7320
 7321
 7322
 7323
 7324
 7325
 7326
 7327
 7328
 7329
 7330
 7331
 7332
 7333
 7334
 7335
 7336
 7337
 7338
 7339
 7340
 7341
 7342
 7343
 7344
 7345
 7346
 7347
 7348
 7349
 7350
 7351
 7352
 7353
 7354
 7355
 7356
 7357
 7358
 7359
 7360
 7361
 7362
 7363
 7364
 7365
 7366
 7367
 7368
 7369
 7370
 7371
 7372
 7373
 7374
 7375
 7376
 7377
 7378
 7379
 7380
 7381
 7382
 7383
 7384
 7385
 7386
 7387
 7388
 7389
 7390
 7391
 7392
 7393
 7394
 7395
 7396
 7397
 7398
 7399
 7400
 7401
 7402
 7403
 7404
 7405
 7406
 7407
 7408
 7409
 7410
 7411
 7412
 7413
 7414
 7415
 7416
 7417
 7418
 7419
 7420
 7421
 7422
 7423
 7424
 7425
 7426
 7427
 7428
 7429
 7430
 7431
 7432
 7433
 7434
 7435
 7436
 7437
 7438
 7439
 7440
 7441
 7442
 7443
 7444
 7445
 7446
 7447
 7448
 7449
 7450
 7451
 7452
 7453
 7454
 7455
 7456
 7457
 7458
 7459
 7460
 7461
 7462
 7463
 7464
 7465
 7466
 7467
 7468
 7469
 7470
 7471
 7472
 7473
 7474
 7475
 7476
 7477
 7478
 7479
 7480
 7481
 7482
 7483
 7484
 7485
 7486
 7487
 7488
 7489
 7490
 7491
 7492
 7493
 7494
 7495
 7496
 7497
 7498
 7499
 7500
 7501
 7502
 7503
 7504
 7505
 7506
 7507
 7508
 7509
 7510
 7511
 7512
 7513
 7514
 7515
 7516
 7517
 7518
 7519
 7520
 7521
 7522
 7523
 7524
 7525
 7526
 7527
 7528
 7529
 7530
 7531
 7532
 7533
 7534
 7535
 7536
 7537
 7538
 7539
 7540
 7541
 7542
 7543
 7544
 7545
 7546
 7547
 7548
 7549
 7550
 7551
 7552
 7553
 7554
 7555
 7556
 7557
 7558
 7559
 7560
 7561
 7562
 7563
 7564
 7565
 7566
 7567
 7568
 7569
 7570
 7571
 7572
 7573
 7574
 7575
 7576
 7577
 7578
 7579
 7580
 7581
 7582
 7583
 7584
 7585
 7586
 7587
 7588
 7589
 7590
 7591
 7592
 7593
 7594
 7595
 7596
 7597
 7598
 7599
 7600
 7601
 7602
 7603
 7604
 7605
 7606
 7607
 7608
 7609
 7610
 7611
 7612
 7613
 7614
 7615
 7616
 7617
 7618
 7619
 7620
 7621
 7622
 7623
 7624
 7625
 7626
 7627
 7628
 7629
 7630
 7631
 7632
 7633
 7634
 7635
 7636
 7637
 7638
 7639
 7640
 7641
 7642
 7643
 7644
 7645
 7646
 7647
 7648
 7649
 7650
 7651
 7652
 7653
 7654
 7655
 7656
 7657
 7658
 7659
 7660
 7661
 7662
 7663
 7664
 7665
 7666
 7667
 7668
 7669
 7670
 7671
 7672
 7673
 7674
 7675
 7676
 7677
 7678
 7679
 7680
 7681
 7682
 7683
 7684
 7685
 7686
 7687
 7688
 7689
 7690
 7691
 7692
 7693
 7694
 7695
 7696
 7697
 7698
 7699
 7700
 7701
 7702
 7703
 7704
 7705
 7706
 7707
 7708
 7709
 7710
 7711
 7712
 7713
 7714
 7715
 7716
 7717
 7718
 7719
 7720
 7721
 7722
 7723
 7724
 7725
 7726
 7727
 7728
 7729
 7730
 7731
 7732
 7733
 7734
 7735
 7736
 7737
 7738
 7739
 7740
 7741
 7742
 7743
 7744
 7745
 7746
 7747
 7748
 7749
 7750
 7751
 7752
 7753
 7754
 7755
 7756
 7757
 7758
 7759
 7760
 7761
 7762
 7763
 7764
 7765
 7766
 7767
 7768
 7769
 7770
 7771
 7772
 7773
 7774
 7775
 7776
 7777
 7778
 7779
 7780
 7781
 7782
 7783
 7784
 7785
 7786
 7787
 7788
 7789
 7790
 7791
 7792
 7793
 7794
 7795
 7796
 7797
 7798
 7799
 7800
 7801
 7802
 7803
 7804
 7805
 7806
 7807
 7808
 7809
 7810
 7811
 7812
 7813
 7814
 7815
 7816
 7817
 7818
 7819
 7820
 7821
 7822
 7823
 7824
 7825
 7826
 7827
 7828
 7829
 7830
 7831
 7832
 7833
 7834
 7835
 7836
 7837
 7838
 7839
 7840
 7841
 7842
 7843
 7844
 7845
 7846
 7847
 7848
 7849
 7850
 7851
 7852
 7853
 7854
 7855
 7856
 7857
 7858
 7859
 7860
 7861
 7862
 7863
 7864
 7865
 7866
 7867
 7868
 7869
 7870
 7871
 7872
 7873
 7874
 7875
 7876
 7877
 7878
 7879
 7880
 7881
 7882
 7883
 7884
 7885
 7886
 7887
 7888
 7889
 7890
 7891
 7892
 7893
 7894
 7895
 7896
 7897
 7898
 7899
 7900
 7901
 7902
 7903
 7904
 7905
 7906
 7907
 7908
 7909
 7910
 7911
 7912
 7913
 7914
 7915
 7916
 7917
 7918
 7919
 7920
 7921
 7922
 7923
 7924
 7925
 7926
 7927
 7928
 7929
 7930
 7931
 7932
 7933
 7934
 7935
 7936
 7937
 7938
 7939
 7940
 7941
 7942
 7943
 7944
 7945
 7946
 7947
 7948
 7949
 7950
 7951
 7952
 7953
 7954
 7955
 7956
 7957
 7958
 7959
 7960
 7961
 7962
 7963
 7964
 7965
 7966
 7967
 7968
 7969
 7970
 7971
 7972
 7973
 7974
 7975
 7976
 7977
 7978
 7979
 7980
 7981
 7982
 7983
 7984
 7985
 7986
 7987
 7988
 7989
 7990
 7991
 7992
 7993
 7994
 7995
 7996
 7997
 7998
 7999
 8000
 8001
 8002
 8003
 8004
 8005
 8006
 8007
 8008
 8009
 8010
 8011
 8012
 8013
 8014
 8015
 8016
 8017
 8018
 8019
 8020
 8021
 8022
 8023
 8024
 8025
 8026
 8027
 8028
 8029
 8030
 8031
 8032
 8033
 8034
 8035
 8036
 8037
 8038
 8039
 8040
 8041
 8042
 8043
 8044
 8045
 8046
 8047
 8048
 8049
 8050
 8051
 8052
 8053
 8054
 8055
 8056
 8057
 8058
 8059
 8060
 8061
 8062
 8063
 8064
 8065
 8066
 8067
 8068
 8069
 8070
 8071
 8072
 8073
 8074
 8075
 8076
 8077
 8078
 8079
 8080
 8081
 8082
 8083
 8084
 8085
 8086
 8087
 8088
 8089
 8090
 8091
 8092
 8093
 8094
 8095
 8096
 8097
 8098
 8099
 8100
 8101
 8102
 8103
 8104
 8105
 8106
 8107
 8108
 8109
 8110
 8111
 8112
 8113
 8114
 8115
 8116
 8117
 8118
 8119
 8120
 8121
 8122
 8123
 8124
 8125
 8126
 8127
 8128
 8129
 8130
 8131
 8132
 8133
 8134
 8135
 8136
 8137
 8138
 8139
 8140
 8141
 8142
 8143
 8144
 8145
 8146
 8147
 8148
 8149
 8150
 8151
 8152
 8153
 8154
 8155
 8156
 8157
 8158
 8159
 8160
 8161
 8162
 8163
 8164
 8165
 8166
 8167
 8168
 8169
 8170
 8171
 8172
 8173
 8174
 8175
 8176
 8177
 8178
 8179
 8180
 8181
 8182
 8183
 8184
 8185
 8186
 8187
 8188
 8189
 8190
 8191
 8192
 8193
 8194
 8195
 8196
 8197
 8198
 8199
 8200
 8201
 8202
 8203
 8204
 8205
 8206
 8207
 8208
 8209
 8210
 8211
 8212
 8213
 8214
 8215
 8216
 8217
 8218
 8219
 8220
 8221
 8222
 8223
 8224
 8225
 8226
 8227
 8228
 8229
 8230
 8231
 8232
 8233
 8234
 8235
 8236
 8237
 8238
 8239
 8240
 8241
 8242
 8243
 8244
 8245
 8246
 8247
 8248
 8249
 8250
 8251
 8252
 8253
 8254
 8255
 8256
 8257
 8258
 8259
 8260
 8261
 8262
 8263
 8264
 8265
 8266
 8267
 8268
 8269
 8270
 8271
 8272
 8273
 8274
 8275
 8276
 8277
 8278
 8279
 8280
 8281
 8282
 8283
 8284
 8285
 8286
 8287
 8288
 8289
 8290
 8291
 8292
 8293
 8294
 8295
 8296
 8297
 8298
 8299
 8300
 8301
 8302
 8303
 8304
 8305
 8306
 8307
 8308
 8309
 8310
 8311
 8312
 8313
 8314
 8315
 8316
 8317
 8318
 8319
 8320
 8321
 8322
 8323
 8324
 8325
 8326
 8327
 8328
 8329
 8330
 8331
 8332
 8333
 8334
 8335
 8336
 8337
 8338
 8339
 8340
 8341
 8342
 8343
 8344
 8345
 8346
 8347
 8348
 8349
 8350
 8351
 8352
 8353
 8354
 8355
 8356
 8357
 8358
 8359
 8360
 8361
 8362
 8363
 8364
 8365
 8366
 8367
 8368
 8369
 8370
 8371
 8372
 8373
 8374
 8375
 8376
 8377
 8378
 8379
 8380
 8381
 8382
 8383
 8384
 8385
 8386
 8387
 8388
 8389
 8390
 8391
 8392
 8393
 8394
 8395
 8396
 8397
 8398
 8399
 8400
 8401
 8402
 8403
 8404
 8405
 8406
 8407
 8408
 8409
 8410
 8411
 8412
 8413
 8414
 8415
 8416
 8417
 8418
 8419
 8420
 8421
 8422
 8423
 8424
 8425
 8426
 8427
 8428
 8429
 8430
 8431
 8432
 8433
 8434
 8435
 8436
 8437
 8438
 8439
 8440
 8441
 8442
 8443
 8444
 8445
 8446
 8447
 8448
 8449
 8450
 8451
 8452
 8453
 8454
 8455
 8456
 8457
 8458
 8459
 8460
 8461
 8462
 8463
 8464
 8465
 8466
 8467
 8468
 8469
 8470
 8471
 8472
 8473
 8474
 8475
 8476
 8477
 8478
 8479
 8480
 8481
 8482
 8483
 8484
 8485
 8486
 8487
 8488
 8489
 8490
 8491
 8492
 8493
 8494
 8495
 8496
 8497
 8498
 8499
 8500
 8501
 8502
 8503
 8504
 8505
 8506
 8507
 8508
 8509
 8510
 8511
 8512
 8513
 8514
 8515
 8516
 8517
 8518
 8519
 8520
 8521
 8522
 8523
 8524
 8525
 8526
 8527
 8528
 8529
 8530
 8531
 8532
 8533
 8534
 8535
 8536
 8537
 8538
 8539
 8540
 8541
 8542
 8543
 8544
 8545
 8546
 8547
 8548
 8549
 8550
 8551
 8552
 8553
 8554
 8555
 8556
 8557
 8558
 8559
 8560
 8561
 8562
 8563
 8564
 8565
 8566
 8567
 8568
 8569
 8570
 8571
 8572
 8573
 8574
 8575
 8576
 8577
 8578
 8579
 8580
 8581
 8582
 8583
 8584
 8585
 8586
 8587
 8588
 8589
 8590
 8591
 8592
 8593
 8594
 8595
 8596
 8597
 8598
 8599
 8600
 8601
 8602
 8603
 8604
 8605
 8606
 8607
 8608
 8609
 8610
 8611
 8612
 8613
 8614
 8615
 8616
 8617
 8618
 8619
 8620
 8621
 8622
 8623
 8624
 8625
 8626
 8627
 8628
 8629
 8630
 8631
 8632
 8633
 8634
 8635
 8636
 8637
 8638
 8639
 8640
 8641
 8642
 8643
 8644
 8645
 8646
 8647
 8648
 8649
 8650
 8651
 8652
 8653
 8654
 8655
 8656
 8657
 8658
 8659
 8660
 8661
 8662
 8663
 8664
 8665
 8666
 8667
 8668
 8669
 8670
 8671
 8672
 8673
 8674
 8675
 8676
 8677
 8678
 8679
 8680
 8681
 8682
 8683
 8684
 8685
 8686
 8687
 8688
 8689
 8690
 8691
 8692
 8693
 8694
 8695
 8696
 8697
 8698
 8699
 8700
 8701
 8702
 8703
 8704
 8705
 8706
 8707
 8708
 8709
 8710
 8711
 8712
 8713
 8714
 8715
 8716
 8717
 8718
 8719
 8720
 8721
 8722
 8723
 8724
 8725
 8726
 8727
 8728
 8729
 8730
 8731
 8732
 8733
 8734
 8735
 8736
 8737
 8738
 8739
 8740
 8741
 8742
 8743
 8744
 8745
 8746
 8747
 8748
 8749
 8750
 8751
 8752
 8753
 8754
 8755
 8756
 8757
 8758
 8759
 8760
 8761
 8762
 8763
 8764
 8765
 8766
 8767
 8768
 8769
 8770
 8771
 8772
 8773
 8774
 8775
 8776
 8777
 8778
 8779
 8780
 8781
 8782
 8783
 8784
 8785
 8786
 8787
 8788
 8789
 8790
 8791
 8792
 8793
 8794
 8795
 8796
 8797
 8798
 8799
 8800
 8801
 8802
 8803
 8804
 8805
 8806
 8807
 8808
 8809
 8810
 8811
 8812
 8813
 8814
 8815
 8816
 8817
 8818
 8819
 8820
 8821
 8822
 8823
 8824
 8825
 8826
 8827
 8828
 8829
 8830
 8831
 8832
 8833
 8834
 8835
 8836
 8837
 8838
 8839
 8840
 8841
 8842
 8843
 8844
 8845
 8846
 8847
 8848
 8849
 8850
 8851
 8852
 8853
 8854
 8855
 8856
 8857
 8858
 8859
 8860
 8861
 8862
 8863
 8864
 8865
 8866
 8867
 8868
 8869
 8870
 8871
 8872
 8873
 8874
 8875
 8876
 8877
 8878
 8879
 8880
 8881
 8882
 8883
 8884
 8885
 8886
 8887
 8888
 8889
 8890
 8891
 8892
 8893
 8894
 8895
 8896
 8897
 8898
 8899
 8900
 8901
 8902
 8903
 8904
 8905
 8906
 8907
 8908
 8909
 8910
 8911
 8912
 8913
 8914
 8915
 8916
 8917
 8918
 8919
 8920
 8921
 8922
 8923
 8924
 8925
 8926
 8927
 8928
 8929
 8930
 8931
 8932
 8933
 8934
 8935
 8936
 8937
 8938
 8939
 8940
 8941
 8942
 8943
 8944
 8945
 8946
 8947
 8948
 8949
 8950
 8951
 8952
 8953
 8954
 8955
 8956
 8957
 8958
 8959
 8960
 8961
 8962
 8963
 8964
 8965
 8966
 8967
 8968
 8969
 8970
 8971
 8972
 8973
 8974
 8975
 8976
 8977
 8978
 8979
 8980
 8981
 8982
 8983
 8984
 8985
 8986
 8987
 8988
 8989
 8990
 8991
 8992
 8993
 8994
 8995
 8996
 8997
 8998
 8999
 9000
 9001
 9002
 9003
 9004
 9005
 9006
 9007
 9008
 9009
 9010
 9011
 9012
 9013
 9014
 9015
 9016
 9017
 9018
 9019
 9020
 9021
 9022
 9023
 9024
 9025
 9026
 9027
 9028
 9029
 9030
 9031
 9032
 9033
 9034
 9035
 9036
 9037
 9038
 9039
 9040
 9041
 9042
 9043
 9044
 9045
 9046
 9047
 9048
 9049
 9050
 9051
 9052
 9053
 9054
 9055
 9056
 9057
 9058
 9059
 9060
 9061
 9062
 9063
 9064
 9065
 9066
 9067
 9068
 9069
 9070
 9071
 9072
 9073
 9074
 9075
 9076
 9077
 9078
 9079
 9080
 9081
 9082
 9083
 9084
 9085
 9086
 9087
 9088
 9089
 9090
 9091
 9092
 9093
 9094
 9095
 9096
 9097
 9098
 9099
 9100
 9101
 9102
 9103
 9104
 9105
 9106
 9107
 9108
 9109
 9110
 9111
 9112
 9113
 9114
 9115
 9116
 9117
 9118
 9119
 9120
 9121
 9122
 9123
 9124
 9125
 9126
 9127
 9128
 9129
 9130
 9131
 9132
 9133
 9134
 9135
 9136
 9137
 9138
 9139
 9140
 9141
 9142
 9143
 9144
 9145
 9146
 9147
 9148
 9149
 9150
 9151
 9152
 9153
 9154
 9155
 9156
 9157
 9158
 9159
 9160
 9161
 9162
 9163
 9164
 9165
 9166
 9167
 9168
 9169
 9170
 9171
 9172
 9173
 9174
 9175
 9176
 9177
 9178
 9179
 9180
 9181
 9182
 9183
 9184
 9185
 9186
 9187
 9188
 9189
 9190
 9191
 9192
 9193
 9194
 9195
 9196
 9197
 9198
 9199
 9200
 9201
 9202
 9203
 9204
 9205
 9206
 9207
 9208
 9209
 9210
 9211
 9212
 9213
 9214
 9215
 9216
 9217
 9218
 9219
 9220
 9221
 9222
 9223
 9224
 9225
 9226
 9227
 9228
 9229
 9230
 9231
 9232
 9233
 9234
 9235
 9236
 9237
 9238
 9239
 9240
 9241
 9242
 9243
 9244
 9245
 9246
 9247
 9248
 9249
 9250
 9251
 9252
 9253
 9254
 9255
 9256
 9257
 9258
 9259
 9260
 9261
 9262
 9263
 9264
 9265
 9266
 9267
 9268
 9269
 9270
 9271
 9272
 9273
 9274
 9275
 9276
 9277
 9278
 9279
 9280
 9281
 9282
 9283
 9284
 9285
 9286
 9287
 9288
 9289
 9290
 9291
 9292
 9293
 9294
 9295
 9296
 9297
 9298
 9299
 9300
 9301
 9302
 9303
 9304
 9305
 9306
 9307
 9308
 9309
 9310
 9311
 9312
 9313
 9314
 9315
 9316
 9317
 9318
 9319
 9320
 9321
 9322
 9323
 9324
 9325
 9326
 9327
 9328
 9329
 9330
 9331
 9332
 9333
 9334
 9335
 9336
 9337
 9338
 9339
 9340
 9341
 9342
 9343
 9344
 9345
 9346
 9347
 9348
 9349
 9350
 9351
 9352
 9353
 9354
 9355
 9356
 9357
 9358
 9359
 9360
 9361
 9362
 9363
 9364
 9365
 9366
 9367
 9368
 9369
 9370
 9371
 9372
 9373
 9374
 9375
 9376
 9377
 9378
 9379
 9380
 9381
 9382
 9383
 9384
 9385
 9386
 9387
 9388
 9389
 9390
 9391
 9392
 9393
 9394
 9395
 9396
 9397
 9398
 9399
 9400
 9401
 9402
 9403
 9404
 9405
 9406
 9407
 9408
 9409
 9410
 9411
 9412
 9413
 9414
 9415
 9416
 9417
 9418
 9419
 9420
 9421
 9422
 9423
 9424
 9425
 9426
 9427
 9428
 9429
 9430
 9431
 9432
 9433
 9434
 9435
 9436
 9437
 9438
 9439
 9440
 9441
 9442
 9443
 9444
 9445
 9446
 9447
 9448
 9449
 9450
 9451
 9452
 9453
 9454
 9455
 9456
 9457
 9458
 9459
 9460
 9461
 9462
 9463
 9464
 9465
 9466
 9467
 9468
 9469
 9470
 9471
 9472
 9473
 9474
 9475
 9476
 9477
 9478
 9479
 9480
 9481
 9482
 9483
 9484
 9485
 9486
 9487
 9488
 9489
 9490
 9491
 9492
 9493
 9494
 9495
 9496
 9497
 9498
 9499
 9500
 9501
 9502
 9503
 9504
 9505
 9506
 9507
 9508
 9509
 9510
 9511
 9512
 9513
 9514
 9515
 9516
 9517
 9518
 9519
 9520
 9521
 9522
 9523
 9524
 9525
 9526
 9527
 9528
 9529
 9530
 9531
 9532
 9533
 9534
 9535
 9536
 9537
 9538
 9539
 9540
 9541
 9542
 9543
 9544
 9545
 9546
 9547
 9548
 9549
 9550
 9551
 9552
 9553
 9554
 9555
 9556
 9557
 9558
 9559
 9560
 9561
 9562
 9563
 9564
 9565
 9566
 9567
 9568
 9569
 9570
 9571
 9572
 9573
 9574
 9575
 9576
 9577
 9578
 9579
 9580
 9581
 9582
 9583
 9584
 9585
 9586
 9587
 9588
 9589
 9590
 9591
 9592
 9593
 9594
 9595
 9596
 9597
 9598
 9599
 9600
 9601
 9602
 9603
 9604
 9605
 9606
 9607
 9608
 9609
 9610
 9611
 9612
 9613
 9614
 9615
 9616
 9617
 9618
 9619
 9620
 9621
 9622
 9623
 9624
 9625
 9626
 9627
 9628
 9629
 9630
 9631
 9632
 9633
 9634
 9635
 9636
 9637
 9638
 9639
 9640
 9641
 9642
 9643
 9644
 9645
 9646
 9647
 9648
 9649
 9650
 9651
 9652
 9653
 9654
 9655
 9656
 9657
 9658
 9659
 9660
 9661
 9662
 9663
 9664
 9665
 9666
 9667
 9668
 9669
 9670
 9671
 9672
 9673
 9674
 9675
 9676
 9677
 9678
 9679
 9680
 9681
 9682
 9683
 9684
 9685
 9686
 9687
 9688
 9689
 9690
 9691
 9692
 9693
 9694
 9695
 9696
 9697
 9698
 9699
 9700
 9701
 9702
 9703
 9704
 9705
 9706
 9707
 9708
 9709
 9710
 9711
 9712
 9713
 9714
 9715
 9716
 9717
 9718
 9719
 9720
 9721
 9722
 9723
 9724
 9725
 9726
 9727
 9728
 9729
 9730
 9731
 9732
 9733
 9734
 9735
 9736
 9737
 9738
 9739
 9740
 9741
 9742
 9743
 9744
 9745
 9746
 9747
 9748
 9749
 9750
 9751
 9752
 9753
 9754
 9755
 9756
 9757
 9758
 9759
 9760
 9761
 9762
 9763
 9764
 9765
 9766
 9767
 9768
 9769
 9770
 9771
 9772
 9773
 9774
 9775
 9776
 9777
 9778
 9779
 9780
 9781
 9782
 9783
 9784
 9785
 9786
 9787
 9788
 9789
 9790
 9791
 9792
 9793
 9794
 9795
 9796
 9797
 9798
 9799
 9800
 9801
 9802
 9803
 9804
 9805
 9806
 9807
 9808
 9809
 9810
 9811
 9812
 9813
 9814
 9815
 9816
 9817
 9818
 9819
 9820
 9821
 9822
 9823
 9824
 9825
 9826
 9827
 9828
 9829
 9830
 9831
 9832
 9833
 9834
 9835
 9836
 9837
 9838
 9839
 9840
 9841
 9842
 9843
 9844
 9845
 9846
 9847
 9848
 9849
 9850
 9851
 9852
 9853
 9854
 9855
 9856
 9857
 9858
 9859
 9860
 9861
 9862
 9863
 9864
 9865
 9866
 9867
 9868
 9869
 9870
 9871
 9872
 9873
 9874
 9875
 9876
 9877
 9878
 9879
 9880
 9881
 9882
 9883
 9884
 9885
 9886
 9887
 9888
 9889
 9890
 9891
 9892
 9893
 9894
 9895
 9896
 9897
 9898
 9899
 9900
 9901
 9902
 9903
 9904
 9905
 9906
 9907
 9908
 9909
 9910
 9911
 9912
 9913
 9914
 9915
 9916
 9917
 9918
 9919
 9920
 9921
 9922
 9923
 9924
 9925
 9926
 9927
 9928
 9929
 9930
 9931
 9932
 9933
 9934
 9935
 9936
 9937
 9938
 9939
 9940
 9941
 9942
 9943
 9944
 9945
 9946
 9947
 9948
 9949
 9950
 9951
 9952
 9953
 9954
 9955
 9956
 9957
 9958
 9959
 9960
 9961
 9962
 9963
 9964
 9965
 9966
 9967
 9968
 9969
 9970
 9971
 9972
 9973
 9974
 9975
 9976
 9977
 9978
 9979
 9980
 9981
 9982
 9983
 9984
 9985
 9986
 9987
 9988
 9989
 9990
 9991
 9992
 9993
 9994
 9995
 9996
 9997
 9998
 9999
10000
10001
10002
10003
10004
10005
10006
10007
10008
10009
10010
10011
10012
10013
10014
10015
10016
10017
10018
10019
10020
10021
10022
10023
10024
10025
10026
10027
10028
10029
10030
10031
10032
10033
10034
10035
10036
10037
10038
10039
10040
10041
10042
10043
10044
10045
10046
10047
10048
10049
10050
10051
10052
10053
10054
10055
10056
10057
10058
10059
10060
10061
10062
10063
10064
10065
10066
10067
10068
10069
10070
10071
10072
10073
10074
10075
10076
10077
10078
10079
10080
10081
10082
10083
10084
10085
10086
10087
10088
10089
10090
10091
10092
10093
10094
10095
10096
10097
10098
10099
10100
10101
10102
10103
10104
10105
10106
10107
10108
10109
10110
10111
10112
10113
10114
10115
10116
10117
10118
10119
10120
10121
10122
10123
10124
10125
10126
10127
10128
10129
10130
10131
10132
10133
10134
10135
10136
10137
10138
10139
10140
10141
10142
10143
10144
10145
10146
10147
10148
10149
10150
10151
10152
10153
10154
10155
10156
10157
10158
10159
10160
10161
10162
10163
10164
10165
10166
10167
10168
10169
10170
10171
10172
10173
10174
10175
10176
10177
10178
10179
10180
10181
10182
10183
10184
10185
10186
10187
10188
10189
10190
10191
10192
10193
10194
10195
10196
10197
10198
10199
10200
10201
10202
10203
10204
10205
10206
10207
10208
10209
10210
10211
10212
10213
10214
10215
10216
10217
10218
10219
10220
10221
10222
10223
10224
10225
10226
10227
10228
10229
10230
10231
10232
10233
10234
10235
10236
10237
10238
10239
10240
10241
10242
10243
10244
10245
10246
10247
10248
10249
10250
10251
10252
10253
10254
10255
10256
10257
10258
10259
10260
10261
10262
10263
10264
10265
10266
10267
10268
10269
10270
10271
10272
10273
10274
10275
10276
10277
10278
10279
10280
10281
10282
10283
10284
10285
10286
10287
10288
10289
10290
10291
10292
10293
10294
10295
10296
10297
10298
10299
10300
10301
10302
10303
10304
10305
10306
10307
10308
10309
10310
10311
10312
10313
10314
10315
10316
10317
10318
10319
10320
10321
10322
10323
10324
10325
10326
10327
10328
10329
10330
10331
10332
10333
10334
10335
10336
10337
10338
10339
10340
10341
10342
10343
10344
10345
10346
10347
10348
10349
10350
10351
10352
10353
10354
10355
10356
10357
10358
10359
10360
10361
10362
10363
10364
10365
10366
10367
10368
10369
10370
10371
10372
10373
10374
10375
10376
10377
10378
10379
10380
10381
10382
10383
10384
10385
10386
10387
10388
10389
10390
10391
10392
10393
10394
10395
10396
10397
10398
10399
10400
10401
10402
10403
10404
10405
10406
10407
10408
10409
10410
10411
10412
10413
10414
10415
10416
10417
10418
10419
10420
10421
10422
10423
10424
10425
10426
10427
10428
10429
10430
10431
10432
10433
10434
10435
10436
10437
10438
10439
10440
10441
10442
10443
10444
10445
10446
10447
10448
10449
10450
10451
10452
10453
10454
10455
10456
10457
10458
10459
10460
10461
10462
10463
10464
10465
10466
10467
10468
10469
10470
10471
10472
10473
10474
10475
10476
10477
10478
10479
10480
10481
10482
10483
10484
10485
10486
10487
10488
10489
10490
10491
10492
10493
10494
10495
10496
10497
10498
10499
10500
10501
10502
10503
10504
10505
10506
10507
10508
10509
10510
10511
10512
10513
10514
10515
10516
10517
10518
10519
10520
10521
10522
10523
10524
10525
10526
10527
10528
10529
10530
10531
10532
10533
10534
10535
10536
10537
10538
10539
10540
10541
10542
10543
10544
10545
10546
10547
10548
10549
10550
10551
10552
10553
10554
10555
10556
10557
10558
10559
10560
10561
10562
10563
10564
10565
10566
10567
10568
10569
10570
10571
10572
10573
10574
10575
10576
10577
10578
10579
10580
10581
10582
10583
10584
10585
10586
10587
10588
10589
10590
10591
10592
10593
10594
10595
10596
10597
10598
10599
10600
10601
10602
10603
10604
10605
10606
10607
10608
10609
10610
10611
10612
10613
10614
10615
10616
10617
10618
10619
10620
10621
10622
10623
10624
10625
10626
10627
10628
10629
10630
10631
10632
10633
10634
10635
10636
10637
10638
10639
10640
10641
10642
10643
10644
10645
10646
10647
10648
10649
10650
10651
10652
10653
10654
10655
10656
10657
10658
10659
10660
10661
10662
10663
10664
10665
10666
10667
10668
10669
10670
10671
10672
10673
10674
10675
10676
10677
10678
10679
10680
10681
10682
10683
10684
10685
10686
10687
10688
10689
10690
10691
10692
10693
10694
10695
10696
10697
10698
10699
10700
10701
10702
10703
10704
10705
10706
10707
10708
10709
10710
10711
10712
10713
10714
10715
10716
10717
10718
10719
10720
10721
10722
10723
10724
10725
10726
10727
10728
10729
10730
10731
10732
10733
10734
10735
10736
10737
10738
10739
10740
10741
10742
10743
10744
10745
10746
10747
10748
10749
10750
10751
10752
10753
10754
10755
10756
10757
10758
10759
10760
10761
10762
10763
10764
10765
10766
10767
10768
10769
10770
10771
10772
10773
10774
10775
10776
10777
10778
10779
10780
10781
10782
10783
10784
10785
10786
10787
10788
10789
10790
10791
10792
10793
10794
10795
10796
10797
10798
10799
10800
10801
10802
10803
10804
10805
10806
10807
10808
10809
10810
10811
10812
10813
10814
10815
10816
10817
10818
10819
10820
10821
10822
10823
10824
10825
10826
10827
10828
10829
10830
10831
10832
10833
10834
10835
10836
10837
10838
10839
10840
10841
10842
10843
10844
10845
10846
10847
10848
10849
10850
10851
10852
10853
10854
10855
10856
10857
10858
10859
10860
10861
10862
10863
10864
10865
10866
10867
10868
10869
10870
10871
10872
10873
10874
10875
10876
10877
10878
10879
10880
10881
10882
10883
10884
10885
10886
10887
10888
10889
10890
10891
10892
10893
10894
10895
10896
10897
10898
10899
10900
10901
10902
10903
10904
10905
10906
10907
10908
10909
10910
10911
10912
10913
10914
10915
10916
10917
10918
10919
10920
10921
10922
10923
10924
10925
10926
10927
10928
10929
10930
10931
10932
10933
10934
10935
10936
10937
10938
10939
10940
10941
10942
10943
10944
10945
10946
10947
10948
10949
10950
10951
10952
10953
10954
10955
10956
10957
10958
10959
10960
10961
10962
10963
10964
10965
10966
10967
10968
10969
10970
10971
10972
10973
10974
10975
10976
10977
10978
10979
10980
10981
10982
10983
10984
10985
10986
10987
10988
10989
10990
10991
10992
10993
10994
10995
10996
10997
10998
10999
11000
11001
11002
11003
11004
11005
11006
11007
11008
11009
11010
11011
11012
11013
11014
11015
11016
11017
11018
11019
11020
11021
11022
11023
11024
11025
11026
11027
11028
11029
11030
11031
11032
11033
11034
11035
11036
11037
11038
11039
11040
11041
11042
11043
11044
11045
11046
11047
11048
11049
11050
11051
11052
11053
11054
11055
11056
11057
11058
11059
11060
11061
11062
11063
11064
11065
11066
11067
11068
11069
11070
11071
11072
11073
11074
11075
11076
11077
11078
11079
11080
11081
11082
11083
11084
11085
11086
11087
11088
11089
11090
11091
11092
11093
11094
11095
11096
11097
11098
11099
11100
11101
11102
11103
11104
11105
11106
11107
11108
11109
11110
11111
11112
11113
11114
11115
11116
11117
11118
11119
11120
11121
11122
11123
11124
11125
11126
11127
11128
11129
11130
11131
11132
11133
11134
11135
11136
11137
11138
11139
11140
11141
11142
11143
11144
11145
11146
11147
11148
11149
11150
11151
11152
11153
11154
11155
11156
11157
11158
11159
11160
11161
11162
11163
11164
11165
11166
11167
11168
11169
11170
11171
11172
11173
11174
11175
11176
11177
11178
11179
11180
11181
11182
11183
11184
11185
11186
11187
11188
11189
11190
11191
11192
11193
11194
11195
11196
11197
11198
11199
11200
11201
11202
11203
11204
11205
11206
11207
11208
11209
11210
11211
11212
11213
11214
11215
11216
11217
11218
11219
11220
11221
11222
11223
11224
11225
11226
11227
11228
11229
11230
11231
11232
11233
11234
11235
11236
11237
11238
11239
11240
11241
11242
11243
11244
11245
11246
11247
11248
11249
11250
11251
11252
11253
11254
11255
11256
11257
11258
11259
11260
11261
11262
11263
11264
11265
11266
11267
11268
11269
11270
11271
11272
11273
11274
11275
11276
11277
11278
11279
11280
11281
11282
11283
11284
11285
11286
11287
11288
11289
11290
11291
11292
11293
11294
11295
11296
11297
11298
11299
11300
11301
11302
11303
11304
11305
11306
11307
11308
11309
11310
11311
11312
11313
11314
11315
11316
11317
11318
11319
11320
11321
11322
11323
11324
11325
11326
11327
11328
11329
11330
11331
11332
11333
11334
11335
11336
11337
11338
11339
11340
11341
11342
11343
11344
11345
11346
11347
11348
11349
11350
11351
11352
11353
11354
11355
11356
11357
11358
11359
11360
11361
11362
11363
11364
11365
11366
11367
11368
11369
11370
11371
11372
11373
11374
11375
11376
11377
11378
11379
11380
11381
11382
11383
11384
11385
11386
11387
11388
11389
11390
11391
11392
11393
11394
11395
11396
11397
11398
11399
11400
11401
11402
11403
11404
11405
11406
11407
11408
11409
11410
11411
11412
11413
11414
11415
11416
11417
11418
11419
11420
11421
11422
11423
11424
11425
11426
11427
11428
11429
11430
11431
11432
11433
11434
11435
11436
11437
11438
11439
11440
11441
11442
11443
11444
11445
11446
11447
11448
11449
11450
11451
11452
11453
11454
11455
11456
11457
11458
11459
11460
11461
11462
11463
11464
11465
11466
11467
11468
11469
11470
11471
11472
11473
11474
11475
11476
11477
11478
11479
11480
11481
11482
11483
11484
11485
11486
11487
11488
11489
11490
11491
11492
11493
11494
11495
11496
11497
11498
11499
11500
11501
11502
11503
11504
11505
11506
11507
11508
11509
11510
11511
11512
11513
11514
11515
11516
11517
11518
11519
11520
11521
11522
11523
11524
11525
11526
11527
11528
11529
11530
11531
11532
11533
11534
11535
11536
11537
11538
11539
11540
11541
11542
11543
11544
11545
11546
11547
11548
11549
11550
11551
11552
11553
11554
11555
11556
11557
11558
11559
11560
11561
11562
11563
11564
11565
11566
11567
11568
11569
11570
11571
11572
11573
11574
11575
11576
11577
11578
11579
11580
11581
11582
11583
11584
11585
11586
11587
11588
11589
11590
11591
11592
11593
11594
11595
11596
11597
11598
11599
11600
11601
11602
11603
11604
11605
11606
11607
11608
11609
11610
11611
11612
11613
11614
11615
11616
11617
11618
11619
11620
11621
11622
11623
11624
11625
11626
11627
11628
11629
11630
11631
11632
11633
11634
11635
11636
11637
11638
11639
11640
11641
11642
11643
11644
11645
11646
11647
11648
11649
11650
11651
11652
11653
11654
11655
11656
11657
11658
11659
11660
11661
11662
11663
11664
11665
11666
11667
11668
11669
11670
11671
11672
11673
11674
11675
11676
11677
11678
11679
11680
11681
11682
11683
11684
11685
11686
11687
11688
11689
11690
11691
11692
11693
11694
11695
11696
11697
11698
11699
11700
11701
11702
11703
11704
11705
11706
11707
11708
11709
11710
11711
11712
11713
11714
11715
11716
11717
11718
11719
11720
11721
11722
11723
11724
11725
11726
11727
11728
11729
11730
11731
11732
11733
11734
11735
11736
11737
11738
11739
11740
11741
11742
11743
11744
11745
11746
11747
11748
11749
11750
11751
11752
11753
11754
11755
11756
11757
11758
11759
11760
11761
11762
11763
11764
11765
11766
11767
11768
11769
11770
11771
11772
11773
11774
11775
11776
11777
11778
11779
11780
11781
11782
11783
11784
11785
11786
11787
11788
11789
11790
11791
11792
11793
11794
11795
11796
11797
11798
11799
11800
11801
11802
11803
11804
11805
11806
11807
11808
11809
11810
11811
11812
11813
11814
11815
11816
11817
11818
11819
11820
11821
11822
11823
11824
11825
11826
11827
11828
11829
11830
11831
11832
11833
11834
11835
11836
11837
11838
11839
11840
11841
11842
11843
11844
11845
11846
11847
11848
11849
11850
11851
11852
11853
11854
11855
11856
11857
11858
11859
11860
11861
11862
11863
11864
11865
11866
11867
11868
11869
11870
11871
11872
11873
11874
11875
11876
11877
11878
11879
11880
11881
11882
11883
11884
11885
11886
11887
11888
11889
11890
11891
11892
11893
11894
11895
11896
11897
11898
11899
11900
11901
11902
11903
11904
11905
11906
11907
11908
11909
11910
11911
11912
11913
11914
11915
11916
11917
11918
11919
11920
11921
11922
11923
11924
11925
11926
11927
11928
11929
11930
11931
11932
11933
11934
11935
11936
11937
11938
11939
11940
11941
11942
11943
11944
11945
11946
11947
11948
11949
11950
11951
11952
11953
11954
11955
11956
11957
11958
11959
11960
11961
11962
11963
11964
11965
11966
11967
11968
11969
11970
11971
11972
11973
11974
11975
11976
11977
11978
11979
11980
11981
11982
11983
11984
11985
11986
11987
11988
11989
11990
11991
11992
11993
11994
11995
11996
11997
11998
11999
12000
12001
12002
12003
12004
12005
12006
12007
12008
12009
12010
12011
12012
12013
12014
12015
12016
12017
12018
12019
12020
12021
12022
12023
12024
12025
12026
12027
12028
12029
12030
12031
12032
12033
12034
12035
12036
12037
12038
12039
12040
12041
12042
12043
12044
12045
12046
12047
12048
12049
12050
12051
12052
12053
12054
12055
12056
12057
12058
12059
12060
12061
12062
12063
12064
12065
12066
12067
12068
12069
12070
12071
12072
12073
12074
12075
12076
12077
12078
12079
12080
12081
12082
12083
12084
12085
12086
12087
12088
12089
12090
12091
12092
12093
12094
12095
12096
12097
12098
12099
12100
12101
12102
12103
12104
12105
12106
12107
12108
12109
12110
12111
12112
12113
12114
12115
12116
12117
12118
12119
12120
12121
12122
12123
12124
12125
12126
12127
12128
12129
12130
12131
12132
12133
12134
12135
12136
12137
12138
12139
12140
12141
12142
12143
12144
12145
12146
12147
12148
12149
12150
12151
12152
12153
12154
12155
12156
12157
12158
12159
12160
12161
12162
12163
12164
12165
12166
12167
12168
12169
12170
12171
12172
12173
12174
12175
12176
12177
12178
12179
12180
12181
12182
12183
12184
12185
12186
12187
12188
12189
12190
12191
12192
12193
12194
12195
12196
12197
12198
12199
12200
12201
12202
12203
12204
12205
12206
12207
12208
12209
12210
12211
12212
12213
12214
12215
12216
12217
12218
12219
12220
12221
12222
12223
12224
12225
12226
12227
12228
12229
12230
12231
12232
12233
12234
12235
12236
12237
12238
12239
12240
12241
12242
12243
12244
12245
12246
12247
12248
12249
12250
12251
12252
12253
12254
12255
12256
12257
12258
12259
12260
12261
12262
12263
12264
12265
12266
12267
12268
12269
12270
12271
12272
12273
12274
12275
12276
12277
12278
12279
12280
12281
12282
12283
12284
12285
12286
12287
12288
12289
12290
12291
12292
12293
12294
12295
12296
12297
12298
12299
12300
12301
12302
12303
12304
12305
12306
12307
12308
12309
12310
12311
12312
12313
12314
12315
12316
12317
12318
12319
12320
12321
12322
12323
12324
12325
12326
12327
12328
12329
12330
12331
12332
12333
12334
12335
12336
12337
12338
12339
12340
12341
12342
12343
12344
12345
12346
12347
12348
12349
12350
12351
12352
12353
12354
12355
12356
12357
12358
12359
12360
12361
12362
12363
12364
12365
12366
12367
12368
12369
12370
12371
12372
12373
12374
12375
12376
12377
12378
12379
12380
12381
12382
12383
12384
12385
12386
12387
12388
12389
12390
12391
12392
12393
12394
12395
12396
12397
12398
12399
12400
12401
12402
12403
12404
12405
12406
12407
12408
12409
12410
12411
12412
12413
12414
12415
12416
12417
12418
12419
12420
12421
12422
12423
12424
12425
12426
12427
12428
12429
12430
12431
12432
12433
12434
12435
12436
12437
12438
12439
12440
12441
12442
12443
12444
12445
12446
12447
12448
12449
12450
12451
12452
12453
12454
12455
12456
12457
12458
12459
12460
12461
12462
12463
12464
12465
12466
12467
12468
12469
12470
12471
12472
12473
12474
12475
12476
12477
12478
12479
12480
12481
12482
12483
12484
12485
12486
12487
12488
12489
12490
12491
12492
12493
12494
12495
12496
12497
12498
12499
12500
12501
12502
12503
12504
12505
12506
12507
12508
12509
12510
12511
12512
12513
12514
12515
12516
12517
12518
12519
12520
12521
12522
12523
12524
12525
12526
12527
12528
12529
12530
12531
12532
12533
12534
12535
12536
12537
12538
12539
12540
12541
12542
12543
12544
12545
12546
12547
12548
12549
12550
12551
12552
12553
12554
12555
12556
12557
12558
12559
12560
12561
12562
12563
12564
12565
12566
12567
12568
12569
12570
12571
12572
12573
12574
12575
12576
12577
12578
12579
12580
12581
12582
12583
12584
12585
12586
12587
12588
12589
12590
12591
12592
12593
12594
12595
12596
12597
12598
12599
12600
12601
12602
12603
12604
12605
12606
12607
12608
12609
12610
12611
12612
12613
12614
12615
12616
12617
12618
12619
12620
12621
12622
12623
12624
12625
12626
12627
12628
12629
12630
12631
12632
12633
12634
12635
12636
12637
12638
12639
12640
12641
12642
12643
12644
12645
12646
12647
12648
12649
12650
12651
12652
12653
12654
12655
12656
12657
12658
12659
12660
12661
12662
12663
12664
12665
12666
12667
12668
12669
12670
12671
12672
12673
12674
12675
12676
12677
12678
12679
12680
12681
12682
12683
12684
12685
12686
12687
12688
12689
12690
12691
12692
12693
12694
12695
12696
12697
12698
12699
12700
12701
12702
12703
12704
12705
12706
12707
12708
12709
12710
12711
12712
12713
12714
12715
12716
12717
12718
12719
12720
12721
12722
12723
12724
12725
12726
12727
12728
12729
12730
12731
12732
12733
12734
12735
12736
12737
12738
12739
12740
12741
12742
12743
12744
12745
12746
12747
12748
12749
12750
12751
12752
12753
12754
12755
12756
12757
12758
12759
12760
12761
12762
12763
12764
12765
12766
12767
12768
12769
12770
12771
12772
12773
12774
12775
12776
12777
12778
12779
12780
12781
12782
12783
12784
12785
12786
12787
12788
12789
12790
12791
12792
12793
12794
12795
12796
12797
12798
12799
12800
12801
12802
12803
12804
12805
12806
12807
12808
12809
12810
12811
12812
12813
12814
12815
12816
12817
12818
12819
12820
12821
12822
12823
12824
12825
12826
12827
12828
12829
12830
12831
12832
12833
12834
12835
12836
12837
12838
12839
12840
12841
12842
12843
12844
12845
12846
12847
12848
12849
12850
12851
12852
12853
12854
12855
12856
12857
12858
12859
12860
12861
12862
12863
12864
12865
12866
12867
12868
12869
12870
12871
12872
12873
12874
12875
12876
12877
12878
12879
12880
12881
12882
12883
12884
12885
12886
12887
12888
12889
12890
12891
12892
12893
12894
12895
12896
12897
12898
12899
12900
12901
12902
12903
12904
12905
12906
12907
12908
12909
12910
12911
12912
12913
12914
12915
12916
12917
12918
12919
12920
12921
12922
12923
12924
12925
12926
12927
12928
12929
12930
12931
12932
12933
12934
12935
12936
12937
12938
12939
12940
12941
12942
12943
12944
12945
12946
12947
12948
12949
12950
12951
12952
12953
12954
12955
12956
12957
12958
12959
12960
12961
12962
12963
12964
12965
12966
12967
12968
12969
12970
12971
12972
12973
12974
12975
12976
12977
12978
12979
12980
12981
12982
12983
12984
12985
12986
12987
12988
12989
12990
12991
12992
12993
12994
12995
12996
12997
12998
12999
13000
13001
13002
13003
13004
13005
13006
13007
13008
13009
13010
13011
13012
13013
13014
13015
13016
13017
13018
13019
13020
13021
13022
13023
13024
13025
13026
13027
13028
13029
13030
13031
13032
13033
13034
13035
13036
13037
13038
13039
13040
13041
13042
13043
13044
13045
13046
13047
13048
13049
13050
13051
13052
13053
13054
13055
13056
13057
13058
13059
13060
13061
13062
13063
13064
13065
13066
13067
13068
13069
13070
13071
13072
13073
13074
13075
13076
13077
13078
13079
13080
13081
13082
13083
13084
13085
13086
13087
13088
13089
13090
13091
13092
13093
13094
13095
13096
13097
13098
13099
13100
13101
13102
13103
13104
13105
13106
13107
13108
13109
13110
13111
13112
13113
13114
13115
13116
13117
13118
13119
13120
13121
13122
13123
13124
13125
13126
13127
13128
13129
13130
13131
13132
13133
13134
13135
13136
13137
13138
13139
13140
13141
13142
13143
13144
13145
13146
13147
13148
13149
13150
13151
13152
13153
13154
13155
13156
13157
13158
13159
13160
13161
13162
13163
13164
13165
13166
13167
13168
13169
13170
13171
13172
13173
13174
13175
13176
13177
13178
13179
13180
13181
13182
13183
13184
13185
13186
13187
13188
13189
13190
13191
13192
13193
13194
13195
13196
13197
13198
13199
13200
13201
13202
13203
13204
13205
13206
13207
13208
13209
13210
13211
13212
13213
13214
13215
13216
13217
13218
13219
13220
13221
13222
13223
13224
13225
13226
13227
13228
13229
13230
13231
13232
13233
13234
13235
13236
13237
13238
13239
13240
13241
13242
13243
13244
13245
13246
13247
13248
13249
13250
13251
13252
13253
13254
13255
13256
13257
13258
13259
13260
13261
13262
13263
13264
13265
13266
13267
13268
13269
13270
13271
13272
13273
13274
13275
13276
13277
13278
13279
13280
13281
13282
13283
13284
13285
13286
13287
13288
13289
13290
13291
13292
13293
13294
13295
13296
13297
13298
13299
13300
13301
13302
13303
13304
13305
13306
13307
13308
13309
13310
13311
13312
13313
13314
13315
13316
13317
13318
13319
13320
13321
13322
13323
13324
13325
13326
13327
13328
13329
13330
13331
13332
13333
13334
13335
13336
13337
13338
13339
13340
13341
13342
13343
13344
13345
13346
13347
13348
13349
13350
13351
13352
13353
13354
13355
13356
13357
13358
13359
13360
13361
13362
13363
13364
13365
13366
13367
13368
13369
13370
13371
13372
13373
13374
13375
13376
13377
13378
13379
13380
13381
13382
13383
13384
13385
13386
13387
13388
13389
13390
13391
13392
13393
13394
13395
13396
13397
13398
13399
13400
13401
13402
13403
13404
13405
13406
13407
13408
13409
13410
13411
13412
13413
13414
13415
13416
13417
13418
13419
13420
13421
13422
13423
13424
13425
13426
13427
13428
13429
13430
13431
13432
13433
13434
13435
13436
13437
13438
13439
13440
13441
13442
13443
13444
13445
13446
13447
13448
13449
13450
13451
13452
13453
13454
13455
13456
13457
13458
13459
13460
13461
13462
13463
13464
13465
13466
13467
13468
13469
13470
13471
13472
13473
13474
13475
13476
13477
13478
13479
13480
13481
13482
13483
13484
13485
13486
13487
13488
13489
13490
13491
13492
13493
13494
13495
13496
13497
13498
13499
13500
13501
13502
13503
13504
13505
13506
13507
13508
13509
13510
13511
13512
13513
13514
13515
13516
13517
13518
13519
13520
13521
13522
13523
13524
13525
13526
13527
13528
13529
13530
13531
13532
13533
13534
13535
13536
13537
13538
13539
13540
13541
13542
13543
13544
13545
13546
13547
13548
13549
13550
13551
13552
13553
13554
13555
13556
13557
13558
13559
13560
13561
13562
13563
13564
13565
13566
13567
13568
13569
13570
13571
13572
13573
13574
13575
13576
13577
13578
13579
13580
13581
13582
13583
13584
13585
13586
13587
13588
13589
13590
13591
13592
13593
13594
13595
13596
13597
13598
13599
13600
13601
13602
13603
13604
13605
13606
13607
13608
13609
13610
13611
13612
13613
13614
13615
13616
13617
13618
13619
13620
13621
13622
13623
13624
13625
13626
13627
13628
13629
13630
13631
13632
13633
13634
13635
13636
13637
13638
13639
13640
13641
13642
13643
13644
13645
13646
13647
13648
13649
13650
13651
13652
13653
13654
13655
13656
13657
13658
13659
13660
13661
13662
13663
13664
13665
13666
13667
13668
13669
13670
13671
13672
13673
13674
13675
13676
13677
13678
13679
13680
13681
13682
13683
13684
13685
13686
13687
13688
13689
13690
13691
13692
13693
13694
13695
13696
13697
13698
13699
13700
13701
13702
13703
13704
13705
13706
13707
13708
13709
13710
13711
13712
13713
13714
13715
13716
13717
13718
13719
13720
13721
13722
13723
13724
13725
13726
13727
13728
13729
13730
13731
13732
13733
13734
13735
13736
13737
13738
13739
13740
13741
13742
13743
13744
13745
13746
13747
13748
13749
13750
13751
13752
13753
13754
13755
13756
13757
13758
13759
13760
13761
13762
13763
13764
13765
13766
13767
13768
13769
13770
13771
13772
13773
13774
13775
13776
13777
13778
13779
13780
13781
13782
13783
13784
13785
13786
13787
13788
13789
13790
13791
13792
13793
13794
13795
13796
13797
13798
13799
13800
13801
13802
13803
13804
13805
13806
13807
13808
13809
13810
13811
13812
13813
13814
13815
13816
13817
13818
13819
13820
13821
13822
13823
13824
13825
13826
13827
13828
13829
13830
13831
13832
13833
13834
13835
13836
13837
13838
13839
13840
13841
13842
13843
13844
13845
13846
13847
13848
13849
13850
13851
13852
13853
13854
13855
13856
13857
13858
13859
13860
13861
13862
13863
13864
13865
13866
13867
13868
13869
13870
13871
13872
13873
13874
13875
13876
13877
13878
13879
13880
13881
13882
13883
13884
13885
13886
13887
13888
13889
13890
13891
13892
13893
13894
13895
13896
13897
13898
13899
13900
13901
13902
13903
13904
13905
13906
13907
13908
13909
13910
13911
13912
13913
13914
13915
13916
13917
13918
13919
13920
13921
13922
13923
13924
13925
13926
13927
13928
13929
13930
13931
13932
13933
13934
13935
13936
13937
13938
13939
13940
13941
13942
13943
13944
13945
13946
13947
13948
13949
13950
13951
13952
13953
13954
13955
13956
13957
13958
13959
13960
13961
13962
13963
13964
13965
13966
13967
13968
13969
13970
13971
13972
13973
13974
13975
13976
13977
13978
13979
13980
13981
13982
13983
13984
13985
13986
13987
13988
13989
13990
13991
13992
13993
13994
13995
13996
13997
13998
13999
14000
14001
14002
14003
14004
14005
14006
14007
14008
14009
14010
14011
14012
14013
14014
14015
14016
14017
14018
14019
14020
14021
14022
14023
14024
14025
14026
14027
14028
14029
14030
14031
14032
14033
14034
14035
14036
14037
14038
14039
14040
14041
14042
14043
14044
14045
14046
14047
14048
14049
14050
14051
14052
14053
14054
14055
14056
14057
14058
14059
14060
14061
14062
14063
14064
14065
14066
14067
14068
14069
14070
14071
14072
14073
14074
14075
14076
14077
14078
14079
14080
14081
14082
14083
14084
14085
14086
14087
14088
14089
14090
14091
14092
14093
14094
14095
14096
14097
14098
14099
14100
14101
14102
14103
14104
14105
14106
14107
14108
14109
14110
14111
14112
14113
14114
14115
14116
14117
14118
14119
14120
14121
14122
14123
14124
14125
14126
14127
14128
14129
14130
14131
14132
14133
14134
14135
14136
14137
14138
14139
14140
14141
14142
14143
14144
14145
14146
14147
14148
14149
14150
14151
14152
14153
14154
14155
14156
14157
14158
14159
14160
14161
14162
14163
14164
14165
14166
14167
14168
14169
14170
14171
14172
14173
14174
14175
14176
14177
14178
14179
14180
14181
14182
14183
14184
14185
14186
14187
14188
14189
14190
14191
14192
14193
14194
14195
14196
14197
14198
14199
14200
14201
14202
14203
14204
14205
14206
14207
14208
14209
14210
14211
14212
14213
14214
14215
14216
14217
14218
14219
14220
14221
14222
14223
14224
14225
14226
14227
14228
14229
14230
14231
14232
14233
14234
14235
14236
14237
14238
14239
14240
14241
14242
14243
14244
14245
14246
14247
14248
14249
14250
14251
14252
14253
14254
14255
14256
14257
14258
14259
14260
14261
14262
14263
14264
14265
14266
14267
14268
14269
14270
14271
14272
14273
14274
14275
14276
14277
14278
14279
14280
14281
14282
14283
14284
14285
14286
14287
14288
14289
14290
14291
14292
14293
14294
14295
14296
14297
14298
14299
14300
14301
14302
14303
14304
14305
14306
14307
14308
14309
14310
14311
14312
14313
14314
14315
14316
14317
14318
14319
14320
14321
14322
14323
14324
14325
14326
14327
14328
14329
14330
14331
14332
14333
14334
14335
14336
14337
14338
14339
14340
14341
14342
14343
14344
14345
14346
14347
14348
14349
14350
14351
14352
14353
14354
14355
14356
14357
14358
14359
14360
14361
14362
14363
14364
14365
14366
14367
14368
14369
14370
14371
14372
14373
14374
14375
14376
14377
14378
14379
14380
14381
14382
14383
14384
14385
14386
14387
14388
14389
14390
14391
14392
14393
14394
14395
14396
14397
14398
14399
14400
14401
14402
14403
14404
14405
14406
14407
14408
14409
14410
14411
14412
14413
14414
14415
14416
14417
14418
14419
14420
14421
14422
14423
14424
14425
14426
14427
14428
14429
14430
14431
14432
14433
14434
14435
14436
14437
14438
14439
14440
14441
14442
14443
14444
14445
14446
14447
14448
14449
14450
14451
14452
14453
14454
14455
14456
14457
14458
14459
14460
14461
14462
14463
14464
14465
14466
14467
14468
14469
14470
14471
14472
14473
14474
14475
14476
14477
14478
14479
14480
14481
14482
14483
14484
14485
14486
14487
14488
14489
14490
14491
14492
14493
14494
14495
14496
14497
14498
14499
14500
14501
14502
14503
14504
14505
14506
14507
14508
14509
14510
14511
14512
14513
14514
14515
14516
14517
14518
14519
14520
14521
14522
14523
14524
14525
14526
14527
14528
14529
14530
14531
14532
14533
14534
14535
14536
14537
14538
14539
14540
14541
14542
14543
14544
14545
14546
14547
14548
14549
14550
14551
14552
14553
14554
14555
14556
14557
14558
14559
14560
14561
14562
14563
14564
14565
14566
14567
14568
14569
14570
14571
14572
14573
14574
14575
14576
14577
14578
14579
14580
14581
14582
14583
14584
14585
14586
14587
14588
14589
14590
14591
14592
14593
14594
14595
14596
14597
14598
14599
14600
14601
14602
14603
14604
14605
14606
14607
14608
14609
14610
14611
14612
14613
14614
14615
14616
14617
14618
14619
14620
14621
14622
14623
14624
14625
14626
14627
14628
14629
14630
14631
14632
14633
14634
14635
14636
14637
14638
14639
14640
14641
14642
14643
14644
14645
14646
14647
14648
14649
14650
14651
14652
14653
14654
14655
14656
14657
14658
14659
14660
14661
14662
14663
14664
14665
14666
14667
14668
14669
14670
14671
14672
14673
14674
14675
14676
14677
14678
14679
14680
14681
14682
14683
14684
14685
14686
14687
14688
14689
14690
14691
14692
14693
14694
14695
14696
14697
14698
14699
14700
14701
14702
14703
14704
14705
14706
14707
14708
14709
14710
14711
14712
14713
14714
14715
14716
14717
14718
14719
14720
14721
14722
14723
14724
14725
14726
14727
14728
14729
14730
14731
14732
14733
14734
14735
14736
14737
14738
14739
14740
14741
14742
14743
14744
14745
14746
14747
14748
14749
14750
14751
14752
14753
14754
14755
14756
14757
14758
14759
14760
14761
14762
14763
14764
14765
14766
14767
14768
14769
14770
14771
14772
14773
14774
14775
14776
14777
14778
14779
14780
14781
14782
14783
14784
14785
14786
14787
14788
14789
14790
14791
14792
14793
14794
14795
14796
14797
14798
14799
14800
14801
14802
14803
14804
14805
14806
14807
14808
14809
14810
14811
14812
14813
14814
14815
14816
14817
14818
14819
14820
14821
14822
14823
14824
14825
14826
14827
14828
14829
14830
14831
14832
14833
14834
14835
14836
14837
14838
14839
14840
14841
14842
14843
14844
14845
14846
14847
14848
14849
14850
14851
14852
14853
14854
14855
14856
14857
14858
14859
14860
14861
14862
14863
14864
14865
14866
14867
14868
14869
14870
14871
14872
14873
14874
14875
14876
14877
14878
14879
14880
14881
14882
14883
14884
14885
14886
14887
14888
14889
14890
14891
14892
14893
14894
14895
14896
14897
14898
14899
14900
14901
14902
14903
14904
14905
14906
14907
14908
14909
14910
14911
14912
14913
14914
14915
14916
14917
14918
14919
14920
14921
14922
14923
14924
14925
14926
14927
14928
14929
14930
14931
14932
14933
14934
14935
14936
14937
14938
14939
14940
14941
14942
14943
14944
14945
14946
14947
14948
14949
14950
14951
14952
14953
14954
14955
14956
14957
14958
14959
14960
14961
14962
14963
14964
14965
14966
14967
14968
14969
14970
14971
14972
14973
14974
14975
14976
14977
14978
14979
14980
14981
14982
14983
14984
14985
14986
14987
14988
14989
14990
14991
14992
14993
14994
14995
14996
14997
14998
14999
15000
15001
15002
15003
15004
15005
15006
15007
15008
15009
15010
15011
15012
15013
15014
15015
15016
15017
15018
15019
15020
15021
15022
15023
15024
15025
15026
15027
15028
15029
15030
15031
15032
15033
15034
15035
15036
15037
15038
15039
15040
15041
15042
15043
15044
15045
15046
15047
15048
15049
15050
15051
15052
15053
15054
15055
15056
15057
15058
15059
15060
15061
15062
15063
15064
15065
15066
15067
15068
15069
15070
15071
15072
15073
15074
15075
15076
15077
15078
15079
15080
15081
15082
15083
15084
15085
15086
15087
15088
15089
15090
15091
15092
15093
15094
15095
15096
15097
15098
15099
15100
15101
15102
15103
15104
15105
15106
15107
15108
15109
15110
15111
15112
15113
15114
15115
15116
15117
15118
15119
class NetCDF(Dataset):
    """NetCDF.

    NetCDF class is a recursive data structure or self-referential object.
    The NetCDF class contains methods to deal with NetCDF files.

    NetCDF Creation guidelines:
        https://acdguide.github.io/Governance/create/create-basics.html
    """

    # NetCDF-only instance attributes assigned outside ``__init__``: the
    # temp-file path tracked for an interop round-trip (set by the interop
    # engine), the 2-D curvilinear coordinate windows carried on a
    # cropped subset (set by the selection engine, read defensively via
    # ``getattr`` in the plot engine), and the weak set of lazy-read file
    # managers created on first use by ``_register_lazy_manager``. The
    # latter is a declaration only — no class-level value — so a fresh
    # instance still reports "nothing tracked yet" through the
    # ``getattr(owner, "_lazy_managers", ...)`` lookups that read it.
    _interop_temp_path: str
    _curvilinear_coords: tuple[Any, Any] | None
    _lazy_managers: weakref.WeakSet[Any]

    def __reduce__(self):  # type: ignore[override]
        """Emit the extended recipe tuple carrying NetCDF mode flags.

        Overrides :meth:`RasterBase.__reduce__` to include
        `_is_md_array`, `_is_subset`, `_source_var_name`, `_group_path`,
        and `_open_options` (the seven-element recipe), which are
        required to reconstruct a container, group view, or
        variable-subset with matching identity and driver options.

        For variable-subset instances the `_file_name` attribute
        reflects the subset's GDAL description, which is typically
        empty or driver-specific. We therefore fall back to the
        parent container's `_file_name` when reconstructing a
        subset.

        Security:
            The recipe carries `_open_options` verbatim, so any secret a
            driver accepts as an open option is serialised into the pickle
            (spilled to disk / quoted in dask error reports). Treat such a
            pickle as a secret.

        Raises:
            TypeError: The NetCDF is in-memory, not on-disk — an empty
                `_file_name`, a `/vsimem/` path, or a `_vsimem_path`
                backing store shadowed by a cosmetic `name=` (checked on
                `self` or, for a subset, the parent). Pickling an
                in-memory NetCDF is not supported.
        """
        path = self._file_name
        parent = getattr(self, "_parent_nc", None)
        if (not path) and (self._is_subset or self._group_path) and parent is not None:
            path = parent._file_name
        # A cosmetic `from_bytes(name=...)` shadows `_file_name`; the real backing store is recorded
        # in `_vsimem_path` (on self, or the parent container for a subset). Consult it so a *named*
        # in-memory NetCDF raises the same immediate TypeError as an unnamed one, instead of pickling
        # into a recipe that only fails on unpickle (#1059; shadow family #1050/#1053/#1057/#1058).
        vsimem = getattr(self, "_vsimem_path", None) or (
            getattr(parent, "_vsimem_path", None) if parent is not None else None
        )
        if not path or path.startswith("/vsimem/") or vsimem:
            raise TypeError(
                f"NetCDF has no on-disk path (file_name={self._file_name!r}); "
                "pickling an in-memory NetCDF is not supported. Call "
                ".to_file(path) first to anchor it to disk."
            )
        return (
            _reconstruct_netcdf,
            (
                path,
                self._access,
                bool(self._is_md_array),
                bool(self._is_subset),
                self._source_var_name,
                self._group_path,
                tuple(self._open_options),
            ),
        )

    def __init__(
        self,
        src: gdal.Dataset,
        access: str = "read_only",
        open_as_multi_dimensional: bool = True,
        *,
        open_options: tuple[str, ...] | list[str] | None = None,
    ):
        """Initialize a NetCDF dataset wrapper.

        Args:
            src: A GDAL dataset handle (either classic or multidimensional).
            access: Access mode, either `"read_only"` or `"write"`.
                Defaults to `"read_only"`.
            open_as_multi_dimensional: If True the dataset was opened with
                `gdal.OF_MULTIDIM_RASTER` and supports groups, MDArrays,
                and dimensions. If False it was opened in classic raster
                mode (subdatasets, bands). Defaults to True.
            open_options: GDAL open options this store was opened with,
                captured so the base reopen paths (per-thread handles,
                lazy `chunks=` reads) reapply them. `None` (default)
                captures nothing (#1025).
        """
        if type(self) is NetCDF:
            # API-1 (#614): NetCDF is now the base of Container / Variable.
            # Direct construction is deprecated; the typed entry points return the right
            # concrete class. Subclass construction (type(self) is a subclass) is silent.
            warnings.warn(
                "Directly constructing NetCDF is deprecated and will stop returning a "
                "usable instance in a future major release. Open a store with "
                "NetCDF.read_file(...) / NetCDF.from_array(...) (returns a "
                "Container) and extract variables with container.get_variable(...) "
                "(returns a Variable). NetCDF remains an isinstance-compatible base "
                "for one major version.",
                DeprecationWarning,
                stacklevel=2,
            )
        super().__init__(src, access=access, open_options=open_options)
        # set the is_subset to false before retrieving the variables
        if open_as_multi_dimensional:
            self._is_md_array = True
            self._is_subset = False
        else:
            self._is_md_array = False
            self._is_subset = False
        # Caches (invalidated by _replace_raster, add_variable, remove_variable)
        self._cached_variables: _LazyVariableDict | None = None
        self._cached_meta_data: NetCDFMetadata | None = None
        # Memoised `geotransform`; see that property. Cleared wherever
        # `_geotransform` is reassigned, so the two cannot disagree.
        self._derived_geotransform: tuple | None = None
        # The grid a rebuild was told to produce, when its coordinates cannot describe it — a
        # spatial axis one cell long carries no spacing to derive one from. `get_variable` hands
        # it to the variables it builds, which have no coordinate arrays of their own, the
        # `geotransform` property answers it ahead of deriving one, and `_restore_stamped_grid`
        # replays it over whatever `_update_inplace` or `_replace_raster` re-derived.
        self._stamped_geotransform: tuple | None = None
        # Origin-tracking attributes set by get_variable (RT-4)
        self._parent_nc: NetCDF | None = None
        self._source_var_name: str | None = None
        # True on a raster rebuilt cell by cell in memory (`where`, `fillna`, the null
        # flags): it keeps `_source_var_name` so it is still called what it is called,
        # but its values are its own and no longer live at `file::source_var_name`. A
        # lazy read, which reopens that path, has to refuse it rather than reach for a
        # store that does not hold these cells.
        self._rebuilt_in_memory: bool = False
        # ARC-12: a group view shares the parent container's open dataset and
        # records the "/"-joined path to its working sub-group here. None (the
        # default) means this container is rooted at the dataset's root group;
        # `_working_group()` resolves the active group from this field so a
        # `get_group()` view reads variables/dims/attrs without copying data.
        self._group_path: str | None = None
        self._gdal_md_arr_ref: Any = None
        self._gdal_rg_ref: Any = None
        # Whether get_variable reversed a south-to-north Y axis for this cube (None until a variable
        # subset is read). The eager materialize path replays it on the fast classic driver; declared
        # here so it is a class invariant snapshotted alongside the _gdal_* refs in _update_inplace.
        self._md_y_flipped: bool | None = None
        # Whether get_variable reversed an east-to-west X axis for this cube. Legal CF, written by
        # nobody in practice, but a raster mirrored west-east is silently wrong if it slips through.
        self._md_x_flipped: bool | None = None
        # (x_index, y_index) of the raster plane within the MDArray's dimensions, resolved by
        # _read_md_array. The eager materialize path needs them to rebuild the unreversed view.
        self._md_spatial_dims: tuple[int, int] | None = None
        # True once the AsClassicDataset view has been replaced by a window-readable MEM raster
        # (see _materialize_md_view). Tracks the raster, so _update_inplace carries it over.
        self._md_view_materialized: bool = False
        # The raster `get_variable` built from the store, while this variable still holds it (see
        # `_reads_as_its_store`). Dropped by `close()` and by a swap to another raster through
        # `_update_inplace` or `_replace_raster`, so it neither keeps the source file open nor keeps
        # a replaced copy of the values alive; `_materialize_md_view` moves it to its MEM copy.
        self._store_raster: gdal.Dataset | None = None
        # True once a geostationary scan-angle geotransform has been rescaled to
        # metres on this cube; tells the `geotransform` property to trust the
        # stored geotransform instead of re-deriving radian spacing from x/y.
        self._geostationary_scaled: bool = False
        # Per-variable cache of the classic-driver geostationary geotransform, populated
        # on the parent container so each variable's metre geotransform is resolved with
        # at most one `NETCDF:<file>:<var>` open instead of re-opening on every access.
        self._geostationary_gt_cache: dict[str, tuple[float, ...] | None] = {}
        # Memoised container CRS borrowed from the first georeferenced variable
        # (see `_container_crs`); None until resolved, "" when no variable has one.
        self._container_crs_cache: str | None = None
        # Memoised fully-resolved CRS (container borrow + CF inference +
        # recorded globals). Same lifetime as the cache above.
        self._crs_cache: str | None = None
        # Whether `epsg` has run its lazy resolution; distinguishes a resolved
        # `None` (no CRS) from "not looked yet" (see the `epsg` property).
        self._epsg_resolved: bool = False
        self._md_array_dims: list[str] = []
        self._band_dim_name: str | None = None
        self._band_dim_values: list[Any] | None = None
        self._band_dim_names: tuple[str, ...] = ()
        self._band_dim_values_map: dict[str, list[Any] | None] = {}
        # `(units, calendar)` of the band dimensions whose units are CF time units (not a level's
        # `millibar`), carried by a result computed in memory, and by a variable `get_variable`
        # built, so their stamps still decode without a store to read them from (see
        # `_time_attr_candidates`).
        self._band_dim_time_attrs: dict[str, tuple[str, str]] = {}
        self._band_dim_sizes: tuple[int, ...] = ()
        self._variable_attrs: dict[str, Any] = {}
        self._scale: float | None = None
        self._offset: float | None = None
        # NetCDF-specific engine collaborators (issue #615, STR-1). Distinct
        # attribute names from the eight inherited Dataset engines so they do
        # not clobber `self.io` / `self.spatial` / … . NetCDF exposes thin
        # façade methods that delegate here (e.g. `nc.<method>()` ->
        # `self.interop.<method>()`).
        self.interop = Interop(self)
        # `varops` (not `variables`) because `variables` is an existing read-side
        # property returning the lazy variable dict — the engine must not shadow it.
        self.varops = Variables(self)
        self.selection = Selection(self)

    def close(self) -> None:
        """Release every GDAL handle this container holds, then close the base.

        A NetCDF container keeps more open GDAL references than the single
        `self._raster` that :meth:`Dataset.close` drops:

        * cached per-variable child objects (:attr:`_cached_variables`), each
          with its own `_raster` and SWIG MDArray / root-group references;
        * the `_gdal_md_arr_ref` / `_gdal_rg_ref` views that keep an
          extracted variable's C++ backing alive;
        * the `_parent_nc` back-reference forming a refcount cycle with the
          parent's variable cache;
        * a variable's `_store_raster`, the record of the raster `get_variable` built, which
          (unless copied into memory) reads the store and so holds a file-backed store's file
          open.

        This override closes the cached children and drops every extra reference
        before deferring to :meth:`Dataset.close`. It then runs a single
        :func:`gc.collect`: a spatial op (`crop` / `to_crs` / `reduce`)
        extracts variables whose `AsClassicDataset` view, MDArray and root
        group form a **reference cycle among the GDAL SWIG wrappers** that plain
        refcounting cannot reclaim. Without the collect those wrappers keep the
        source file open, so on Windows a later `os.replace` / `os.remove`
        fails with `PermissionError` until the caller forces a GC themselves
        (#564). Doing it here makes `close()` honour its contract — the file is
        unlocked immediately. Safe to call more than once.
        """
        # Release handles parked by this container's lazy reads (#727): a lazy dask array can be held
        # alive across close() (the finalizer only fires on drop), so close() explicitly evicts them
        # here so a subsequent reopen of the same file does not leave two live handles. The lazy array
        # stays usable -- its CachingFileManager re-opens on the next chunk read.
        self._release_lazy_managers()
        cached = self._cached_variables
        if cached is not None:
            # Only the materialised children (bypass the lazy dict's loading
            # ``values()`` so closing does not force every variable open).
            for child in dict.values(cached):
                if isinstance(child, Dataset):
                    child.close()
            self._cached_variables = None
        self._gdal_md_arr_ref = None
        self._gdal_rg_ref = None
        # The ad-hoc view/warp keep-alive pins are set only on some code paths (a
        # GetView Y-flip or a to_crs warp), so they are not initialised in __init__;
        # clear them here too — otherwise they hold their backing GDAL handle past
        # close(), defeating the handle-release contract the gc.collect() below enforces.
        self._view_source = None
        self._warp_source = None
        self._parent_nc = None
        # Unless copied into memory, the raster `get_variable` recorded reads the store (the
        # `AsClassicDataset` view of a multidimensional open, or the file itself opened classic), so
        # keeping it would keep a file-backed store's file open.
        self._store_raster = None
        self._cached_meta_data = None
        # Drop and close the reopened geolocation-source handle (a base Dataset over
        # NETCDF:"<file>":<var>, memoised by `_geolocation_source`). Left open it keeps the source
        # file open past close(), defeating the handle-release contract the gc.collect() below
        # enforces (#564); `_update_inplace` already pops it. Only a genuine reopen is cached
        # (never `self`), so closing it is safe.
        geoloc_memo = self.__dict__.pop("_geolocation_source_memo", None)
        if isinstance(geoloc_memo, Dataset):
            geoloc_memo.close()
        super().close()
        # Break the GDAL SWIG view/MDArray/root-group cycle left by variable
        # extraction so the source file is released now, not on the next GC.
        gc.collect()

    def _update_inplace(  # type: ignore[override]
        self, src: gdal.Dataset, access: str | None = None
    ) -> None:
        """Swap internal state, preserving NetCDF-specific attributes.

        The base `Dataset._update_inplace` rebuilds via
        `type(self)(src, access)` and overwrites `self.__dict__`.
        For a NetCDF that runs `NetCDF.__init__` with a default
        `open_as_multi_dimensional=True`, which would reset
        `_is_md_array` to True and clear every variable-subset
        attribute. This override snapshots the subset state, runs the
        base swap with the current MDIM mode, then restores the
        snapshot — so a variable subset stays a subset across
        `set_crs`, `apply(inplace=True)`, `change_no_data_value`,
        and the `epsg` setter.

        The snapshot includes the variable's own `_scale` / `_offset`, which is
        right for a swap that leaves the values alone. An in-place compute
        (`apply` / `fill` with `inplace=True`) writes physical values, so it
        calls `_spend_packing` after this swap to drop them.

        The record of the raster `get_variable` built (`_store_raster`) is kept only when `src`
        is that raster, as the `epsg` setter passes it. Any other `src` drops it, so the variable
        stops streaming a reduction from its store and the replaced raster is not kept alive.

        The rebuild measures its geotransform and cell size from `src`, which is not the grid a
        collapsed spatial axis was stamped with, so `_restore_stamped_grid` runs once the
        snapshot is back: the stamp itself is in `preserved`, but the cell size derived from it
        is not.
        """
        preserved = {
            "_is_md_array": self._is_md_array,
            "_is_subset": self._is_subset,
            "_parent_nc": self._parent_nc,
            "_source_var_name": self._source_var_name,
            "_group_path": self._group_path,
            "_gdal_md_arr_ref": self._gdal_md_arr_ref,
            "_gdal_rg_ref": self._gdal_rg_ref,
            "_md_y_flipped": self._md_y_flipped,
            "_md_x_flipped": self._md_x_flipped,
            "_md_spatial_dims": self._md_spatial_dims,
            "_md_view_materialized": self._md_view_materialized,
            "_geostationary_scaled": self._geostationary_scaled,
            "_md_array_dims": self._md_array_dims,
            "_band_dim_name": self._band_dim_name,
            "_band_dim_values": self._band_dim_values,
            "_band_dim_names": self._band_dim_names,
            "_band_dim_values_map": self._band_dim_values_map,
            "_band_dim_sizes": self._band_dim_sizes,
            "_band_dim_time_attrs": self._band_dim_time_attrs,
            "_stamped_geotransform": self._stamped_geotransform,
            "_variable_attrs": self._variable_attrs,
            "_scale": self._scale,
            "_offset": self._offset,
            # The record survives only a swap to the raster it names (the `epsg` setter's); any other
            # raster is not the one `get_variable` built, and the replaced one must not be kept alive.
            "_store_raster": self._store_raster if src is self._store_raster else None,
        }
        # Rebuild via the concrete subclass (Container / Variable) so an
        # in-place update never downgrades the instance's type back to the base NetCDF.
        new = type(self)(
            src,
            access=access or self._access,
            open_as_multi_dimensional=self._is_md_array,
        )
        self.__dict__.update(new.__dict__)
        self.__dict__.update(preserved)
        self._restore_stamped_grid()
        # collaborators in `new.__dict__` point at
        # `new` via `weakref.proxy`; re-bind to a proxy of `self`
        # so callers using `self.spatial.crop(...)` after this update
        # reach the surviving instance instead of the discarded `new`.
        self_proxy = weakref.proxy(self)
        # Reuse the canonical Dataset collaborator list (single source of truth in dataset.py) so a
        # new engine — e.g. `georef` — is rebound here too instead of silently keeping a dead
        # back-ref, then rebind the NetCDF-specific engines (`interop`, …) the same way.
        for attr in (*_COLLABORATOR_ATTRS, *_NETCDF_COLLABORATOR_ATTRS):
            collab = self.__dict__.get(attr)
            if collab is not None:
                collab._ds = self_proxy
        # Drop cached registered accessors so they rebuild against the new raster
        # (this override does not chain `super()`, so the call is repeated here).
        _invalidate_cached_accessors(self)
        # Drop the memoised classic geolocation handle so it is re-resolved.
        self.__dict__.pop("_geolocation_source_memo", None)
        # ... and the resolved axis-coordinate names, which are read off the store's layout.
        self.__dict__.pop("_coordinate_candidate_cache", None)
        # An in-place swap installs a raster holding *different* values — the whole point of
        # `apply(inplace=True)`, `fill` and `add_band`. The multidim references above are preserved
        # deliberately (this is still that variable of that file), but they no longer describe what
        # `self._raster` holds, so a read that goes back to the MDArray returns the pre-mutation
        # data. Nothing else can notice: the swap keeps the grid size, so a shape check sees
        # nothing. Record the divergence so `_window_via_mdarray` stops serving windows from the
        # source, and the crop falls back to reading the raster it was actually given.
        self._raster_diverged_from_source = True

    def __str__(self):
        """Return a human-readable summary, or a `<Dataset: closed>` sentinel when closed.

        The summary is chosen by **type**, through :meth:`_summary_text`, which
        :class:`Container` and :class:`Variable` override. Defining one summary here and
        letting both inherit it made the container describe raster fields it does not have:
        `rows` / `columns` / `cell_size` fell through to GDAL's in-memory placeholder,
        so a 12x5x5 cube at 0.25 degrees reported itself as 512 x 512 at cell size 1.0
        (#1090).

        Type rather than `band_count`: a container opened in classic mode carries the
        store's bands directly, so `band_count >= 1` there and a band-count test would
        label it a variable. The classes already encode the distinction `read_file` and
        `get_variable` established, so they are the discriminator.

        Mirrors `Dataset.__str__`: a closed handle returns the sentinel rather than
        raising, so the repr/str stays total for debuggers and logging (`__repr__`
        already inherits this via `super()`).

        This changes `str()` / `print()` only. `__repr__` is a separate contract --
        `gdal.Info` on the underlying raster -- and is deliberately unchanged, so the
        surfaces that auto-display an object still report the placeholder: a bare `nc` in a
        notebook cell, a debugger's variable pane and pytest's assertion output all call
        `repr`, and a container still shows `Size is 512, 512` there. Reworking that is a
        separate question about `Dataset.__repr__` and its `gdal.Info` contract.

        Returns:
            str: The type-specific summary; `<Dataset: closed>` when the handle is closed,
                or `<Container: summary unavailable>` / `<Variable: summary unavailable>`
                when building the summary raised.
        """
        message = "<Dataset: closed>"
        if self._raster is not None:
            try:
                message = self._summary_text()
            except Exception as error:
                # Total by contract, not just for a closed handle. `str()` runs in debuggers,
                # logging and pytest introspection, where a raising summary masks the error the
                # caller was actually looking at. The summary walks a dozen properties, any of
                # which can fail on a half-built or exotic store, so degrade to a sentinel that
                # still says what this is -- and log what went wrong, so the failure is
                # diagnosable rather than merely survivable.
                logger.debug(
                    "%s summary failed: %r", type(self).__name__, error, exc_info=True
                )
                message = f"<{type(self).__name__}: summary unavailable>"
        return message

    def _summary_text(self) -> str:
        """The summary body for a bare `NetCDF`, which neither factory produces.

        :class:`Container` and :class:`Variable` override this; the base only has to cope
        with an instance of the deprecated alias itself. It defers to
        :attr:`_is_root_container` -- the predicate the package already owns for this
        question -- rather than spelling the conjunction out again.

        Returns:
            str: A container or variable summary.
        """
        if self._is_root_container:
            return _container_summary(self)
        return _variable_summary(self)

    def _crs_label(self) -> str:
        """The CRS as a short label -- `EPSG:4326`, a CRS name, or `unknown`.

        Never the raw WKT: it is thousands of characters on one line and drowns every
        other line of the summary (#1090).

        Returns:
            str: A one-line CRS label.
        """
        label = "unknown"
        try:
            epsg = self.epsg
            if epsg:
                label = f"EPSG:{epsg}"
            else:
                wkt = self.crs
                if wkt:
                    name = osr.SpatialReference(wkt=wkt).GetName()
                    label = name or "unknown"
        except Exception as error:
            # `unknown` is also what a store with no CRS reports, so a genuine `epsg` / `crs`
            # bug would be indistinguishable from an honest absence without this line.
            logger.debug("CRS label failed: %r", error, exc_info=True)
            label = "unknown"
        return label

    def __repr__(self):
        """__repr__."""
        return super().__repr__()

    @property
    def top_left_corner(self):
        """Top left corner coordinates."""
        xmin, _, _, ymax, _, _ = self._geotransform
        return xmin, ymax

    @property
    def lon(self) -> np.typing.NDArray:
        """Longitude / x-coordinate values as a 1D array.

        Looks for a variable named `"lon"`, then `"longitude"`, then `"x"` — the three
        spellings `cf.build_coordinate_attrs` already treats as the same axis. Knowing
        only `lon` and `x` meant a store that spells it `longitude` — which every CF file
        this package reads may — found nothing here, and `_compute_geotransform` fell back
        to GDAL's index-space placeholder, so the file reported pixel indices as world
        coordinates.

        Returns:
            np.ndarray: The stored coordinates, flattened. When the store spells the axis
            none of those three ways it is not an error and not `None`: the axis derived
            from the geotransform is returned instead, exactly as
            :attr:`pyramids.dataset.Dataset.lon` builds it.

        Examples:
            - A store that names its column axis `longitude` reports its own coordinates,
              which is what `spatial_names` puts there:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import GeoReference, NetCDF
                >>> named = NetCDF.from_array(
                ...     np.zeros((2, 2)),
                ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
                ...     variable_name="t",
                ...     spatial_names=("latitude", "longitude"),
                ... )
                >>> sorted(named.dimension_names)
                ['latitude', 'longitude']
                >>> named.lon.tolist()
                [0.5, 1.5]

                ```
            - The default `y` / `x` naming reaches the same values through the third
              spelling, so both stores answer alike:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import GeoReference, NetCDF
                >>> plain = NetCDF.from_array(
                ...     np.zeros((2, 2)),
                ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
                ...     variable_name="t",
                ... )
                >>> sorted(plain.dimension_names)
                ['x', 'y']
                >>> plain.lon.tolist()
                [0.5, 1.5]

                ```

        See Also:
            - :attr:`lat`: The same three spellings on the row axis.
        """
        lon = None
        for name in _X_AXIS_VARIABLE_NAMES:
            lon = self._read_variable(name)
            if lon is not None:
                break

        result: np.ndarray
        if lon is not None:
            result = lon.reshape(lon.size)
        else:
            result = super().lon
        return result

    @property
    def lat(self) -> np.typing.NDArray:
        """Latitude / y-coordinate values as a 1D array.

        Looks for a variable named `"lat"`, then `"latitude"`, then `"y"` — the spellings
        :attr:`lon` accepts, on the other axis.

        Returns:
            np.ndarray: The stored coordinates, flattened. When the store spells the axis
            none of those three ways the geotransform-derived axis is returned instead,
            never `None` — see :attr:`lon`.

        Examples:
            - A store that names its row axis `latitude` reports its own coordinates, and
              a north-up grid's rows descend from north to south:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import GeoReference, NetCDF
                >>> named = NetCDF.from_array(
                ...     np.zeros((2, 2)),
                ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
                ...     variable_name="t",
                ...     spatial_names=("latitude", "longitude"),
                ... )
                >>> named.lat.tolist()
                [1.5, 0.5]

                ```
            - The default `y` / `x` naming answers with the same values:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import GeoReference, NetCDF
                >>> plain = NetCDF.from_array(
                ...     np.zeros((2, 2)),
                ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
                ...     variable_name="t",
                ... )
                >>> plain.lat.tolist()
                [1.5, 0.5]

                ```

        See Also:
            - :attr:`lon`: The same three spellings on the column axis.
        """
        lat = None
        for name in _Y_AXIS_VARIABLE_NAMES:
            lat = self._read_variable(name)
            if lat is not None:
                break

        result: np.ndarray
        if lat is not None:
            result = lat.reshape(lat.size)
        else:
            result = super().lat
        return result

    @property
    def x(self) -> np.typing.NDArray:
        """x-coordinate/longitude."""
        # Returns the stored x/lon coordinate variable (see `lon`); only when the file
        # has no such coordinate does it fall back to the geotransform-derived axis.
        return self.lon

    @property
    def y(self) -> np.typing.NDArray:
        """y-coordinate/latitude."""
        # Returns the stored y/lat coordinate variable (see `lat`); only when the file
        # has no such coordinate does it fall back to the geotransform-derived axis.
        return self.lat

    @property
    def geotransform(self):
        """Geotransform, derived from the coordinate arrays and then cached.

        Deriving it reads the `lon` / `lat` MDArrays, which costs about 13 ms
        on a modest container. `RasterBase.transform` reads this property on
        every access, and `xy` / `_get_indices` read `transform` per call, so
        an uncached derivation made a loop of point lookups re-read the
        coordinate arrays once per point. The value is memoised and cleared
        wherever `_geotransform` itself is reassigned.

        Returns:
            tuple[float, float, float, float, float, float]: The GDAL
            geotransform.
        """
        if self._derived_geotransform is None:
            self._derived_geotransform = self._compute_geotransform()
        return self._derived_geotransform

    def _compute_geotransform(self):
        """Derive the geotransform from the coordinate arrays.

        Computes from lon/lat coordinate arrays if available.
        Falls back to the parent GDAL GetGeoTransform() otherwise.

        A stamped grid is the other exception, and takes precedence over every case below: an
        operation that rebuilt this raster was told what grid it produced
        (`_stamped_geotransform`), because a spatial axis left one cell long carries no spacing
        for the coordinates to describe. This is what keeps the grid right after a swap cleared
        the memoised value; the matching `cell_size`, which no property re-derives, is put back
        by `_restore_stamped_grid`.

        Geostationary scan-angle datasets are the exception: once their
        ``x`` / ``y`` radians have been rescaled to metres on read (see
        :meth:`_normalize_geostationary_geotransform`), re-deriving the
        geotransform from the raw radian coordinates would be wrong, so the
        stored metre geotransform is authoritative. The check is a cheap
        boolean flag, not an SRS parse, so it adds no cost to ordinary reads.

        Returns:
            tuple[float, float, float, float, float, float]: The GDAL
            geotransform ``(x_min, pixel_width, row_rotation, y_max,
            column_rotation, pixel_height)``. ``pixel_height`` is negative for
            a north-up raster. Units follow the dataset CRS (degrees for
            geographic, metres for projected, including rescaled geostationary).
        """
        if self._stamped_geotransform is not None:
            return self._stamped_geotransform
        if self._geostationary_scaled:
            return self._geotransform
        if self.lon is not None and self.lat is not None:
            # Derive the X and Y pixel sizes independently from the lon/lat coordinate spacing —
            # they differ on non-square grids (e.g. 2° lon, 1° lat), so a single `cell_size` for
            # both axes would stretch the latitude axis. Y is reported north-up: the top edge is the
            # northernmost latitude plus half a cell, and pixel height is negative.
            if len(self.lat) >= 2:
                y_cell = abs(float(self.lat[1] - self.lat[0]))
                y_top = max(float(self.lat[0]), float(self.lat[-1])) + y_cell / 2
            else:
                y_cell = self.cell_size
                y_top = float(self.lat[0]) + y_cell / 2
            # West edge = min(lon) - half a cell, mirroring the north-up lat branch above. Taking
            # `lon[0]` unguarded put the origin at the EAST edge for a descending-longitude container,
            # mirroring the X origin (ARC-32). The X pixel size comes from the real lon spacing too:
            # `self.cell_size` tracks GDAL's index-space geotransform (pixel width 1.0) for a
            # multidim-opened container, which silently mis-georeferenced the X axis (#1014).
            if len(self.lon) >= 2:
                x_cell = abs(float(self.lon[1] - self.lon[0]))
                x_west = min(float(self.lon[0]), float(self.lon[-1])) - x_cell / 2
            else:
                # A single longitude gives no spacing to measure, so fall back to
                # `self.cell_size`. On a multidim-opened container that is the
                # index-space 1.0 — a projected one-column grid cannot recover its
                # real cell size from a lone coordinate (same limit as the y branch).
                x_cell = self.cell_size
                x_west = float(self.lon[0]) - x_cell / 2
            return (
                x_west,
                x_cell,
                0,
                y_top,
                0,
                -y_cell,
            )
        return self._geotransform

    def _restore_stamped_grid(self) -> None:
        """Put a stamped grid back after the raster it describes was rebuilt.

        An operation that collapses a spatial axis knows the grid it produced and records it in
        `_stamped_geotransform`, because the rebuilt store cannot describe it: one coordinate
        value carries no spacing. The two places that re-derive the raster's state from the
        raster they were handed call this right after doing so: `_update_inplace` (the `epsg`
        setter, an in-place `apply`) and `_replace_raster` (a copying `add_variable`). Both
        compute the geotransform and the cell size from that raster, which is the source's grid
        and not the stamped one.

        `_cell_size` is what this alone puts back — leave it out and a weighted result reports
        the replacement raster's width, `1.0` where the stamp says `1.25`, while its variables
        still say `1.25`. `geotransform` recovers without help, since `_compute_geotransform`
        answers the stamp before deriving anything.

        Carrying an auxiliary variable onto a result does **not** arrive here:
        `_carry_aux_variables` uses `add_variable(copy=False)`, which mutates the raster in
        place. That still drops the memoised `_derived_geotransform`, but `_geotransform` and
        `_cell_size` survive it and the derivation that follows answers the stamp, so the grid
        needs no restoring on that path.

        A no-op on any raster that was never stamped, which is every raster read from a file.
        """
        if self._stamped_geotransform is not None:
            self._geotransform = self._stamped_geotransform
            self._derived_geotransform = self._stamped_geotransform
            self._cell_size = GeoTransform(*self._stamped_geotransform).cell_size

    def _is_geostationary(self) -> bool:
        """True when the dataset CRS is the CF geostationary projection.

        Parses the SRS, so it is called sparingly (during read normalization,
        not from the hot `geotransform` property — that uses the
        `_geostationary_scaled` flag instead).
        """
        return self._dataset_is_geostationary(self._raster)

    @staticmethod
    def _dataset_is_geostationary(dataset) -> bool:
        """True when ``dataset``'s CRS is the CF geostationary projection.

        Takes the raster rather than ``self`` so the orientation predicates can ask it of the
        ``AsClassicDataset`` view before any cube exists. See `_mdim.dataset_is_geostationary`.
        """
        return dataset_is_geostationary(dataset)

    def _crs_from_global_attrs(self) -> str:
        """CRS from the ``crs_wkt`` / ``epsg`` global attributes, or ``""``.

        :meth:`pyramids.dataset.DatasetCollection.to_netcdf` writes the grid's
        CRS as NetCDF global attributes (it goes through the GDAL multidim writer,
        which has no place to hang a spatial reference), so reading one of our own
        files back has to look there. GDAL surfaces them as ``NC_GLOBAL#crs_wkt``
        and ``NC_GLOBAL#epsg``.

        Two rules keep a foreign file's attributes from defining a CRS the file
        never claimed:

        * **Root only.** Attributes are read from the *root* group, matching the
          ``NC_GLOBAL#`` filter on the classic path. A sub-group's own attributes
          are not globals, so ``container.get_group("g1")`` no longer resolves a
          CRS from an attribute that applies to ``g1`` alone.
        * **Written as a pair.** The same writer always emits ``GeoTransform``
          beside ``crs_wkt`` / ``epsg``, so the CRS attribute is adopted only when
          that companion is present. A stray root attribute named ``epsg`` on a
          third-party store -- a processing-level tag, a source-data note -- no
          longer becomes the dataset's CRS.

        Returns:
            str: WKT built from those attributes, or ``""`` when they are absent,
            unusable, or carry no ``GeoTransform`` companion.
        """
        result = ""
        try:
            metadata = self.raster.GetMetadata() or {}
            # Only NC_GLOBAL attributes count. Stripping the prefix off every key
            # would let an `epsg` attribute on any *variable* in a foreign file
            # define the whole dataset's CRS.
            attrs = {
                key.split("#", 1)[1].lower(): value
                for key, value in metadata.items()
                if key.upper().startswith("NC_GLOBAL#")
            }
            # A multidim open exposes globals as root-group attributes rather
            # than through GetMetadata(), so consult the group too. Prefer the
            # real root: `_gdal_rg_ref` is the *sub-group* for a `get_group()`
            # view, and a sub-group's attributes are not globals.
            group = self._root_group_ref()
            if group is not None:
                for attribute in group.GetAttributes():
                    attrs.setdefault(
                        attribute.GetName().lower(), attribute.ReadAsString()
                    )
            wkt = attrs.get("crs_wkt")
            code = attrs.get("epsg")
            if not attrs.get("geotransform"):
                # No companion GeoTransform: these attributes were not written by
                # the geobox writer, so they are not a provenance-backed CRS
                # declaration (ARC-26).
                wkt = code = None
            if wkt:
                # Validate before adopting: this attribute is file-controlled, and
                # a junk string would otherwise surface from `.crs` and blow up
                # inside GDAL on the next `.epsg` read.
                candidate = sr_from_wkt(str(wkt))
                # `Validate()` is stricter than the WKT we ourselves emit, so
                # test usability instead: a junk string parses to an SRS that is
                # neither geographic, projected, local nor geocentric.
                # IsLocal() is deliberately absent: a LOCAL_CS is an
                # engineering frame with no earth reference, so accepting one
                # would let a placeholder attribute stand in for a real CRS --
                # the same fabrication this change removes.
                usable = (
                    candidate.IsGeographic()
                    or candidate.IsProjected()
                    or candidate.IsGeocentric()
                )
                if usable:
                    result = candidate.ExportToWkt()
            elif code:
                result = sr_from_epsg(int(code)).ExportToWkt()
        except (RuntimeError, AttributeError, ValueError, TypeError):
            # See `_container_crs`: narrow so a real fault still surfaces.
            result = ""
        return result

    def _multidim_group(self):
        """The multidim root group backing this cube, or ``None``.

        A variable subset holds no group of its own, so fall back to the parent
        container's.

        Returns:
            The GDAL root group, or ``None`` when neither carries one.
        """
        group = getattr(self, "_gdal_rg_ref", None)
        if group is None:
            parent = getattr(self, "_parent_nc", None)
            if parent is not None:
                group = getattr(parent, "_gdal_rg_ref", None)
        if group is None and getattr(self, "_is_md_array", False):
            # A container keeps no `_gdal_rg_ref` of its own -- without this the
            # CF inference was a no-op on every container, masked only by the
            # variable-borrow that usually answers first.
            try:
                group = self.raster.GetRootGroup()
            except (RuntimeError, AttributeError):
                group = None
        return group

    def _root_group_ref(self):
        """The *root* group of the backing NetCDF, or ``None``.

        Distinct from :meth:`_multidim_group`, which answers "the group holding
        this cube's arrays" and is deliberately the sub-group for a
        :meth:`get_group` view. Global attributes are a property of the file, so
        they must come from the root — reading them off a sub-group made
        ``container.get_group("g1").epsg`` resolve from an attribute that applies
        to ``g1`` alone, and made the classic and multidim branches disagree
        about what "global" means for the same file.

        Returns:
            The GDAL root group -- this cube's own when it has one, else the
            container it was carved from, since a materialised view's `_raster`
            is an in-memory dataset with no root group of its own. ``None`` when
            neither exposes one (a classic open).
        """
        result = None
        # A materialised view's own `_raster` is a MEM dataset with no root
        # group, so fall back to the container it was carved from.
        for candidate in (self, getattr(self, "_parent_nc", None)):
            # `is not None`, not truthiness: this asks whether a candidate
            # exists, and `Dataset.__bool__` refuses to answer a yes/no about a
            # raster at all.
            raster = (
                getattr(candidate, "_raster", None) if candidate is not None else None
            )
            if raster is None:
                continue
            try:
                result = raster.GetRootGroup()
            except (RuntimeError, AttributeError):
                result = None
            if result is not None:
                break
        return result

    @staticmethod
    def _collect_axis_units(group) -> tuple[set[str], set[str]]:
        """Units on every coordinate array, and separately on the horizontal axes.

        Only a *coordinate* array is evidence for a geographic reading: a
        dimension's indexing variable, or an array named in a ``coordinates``
        attribute (how CF identifies a curvilinear grid's 2-D lat/lon, which
        index no dimension). A data variable in ``degrees_east`` -- a wind
        direction, a solar angle -- says nothing about the grid. Only the
        *horizontal* axes may veto it, since a depth in metres says nothing about
        the horizontal frame either.

        Args:
            group: A GDAL multidim root group.

        Returns:
            tuple[set[str], set[str]]: `(coordinate units, horizontal axis
            units)`, lower-cased.
        """
        arrays = {
            name.rsplit("/", 1)[-1].lower(): group.OpenMDArray(name)
            for name in group.GetMDArrayNames()
        }
        coordinate_names = NetCDF._coordinate_names(arrays.values())
        axis_units: set[str] = set()
        for array in arrays.values():
            # Only a data array defines the grid. A 1-D coordinate array's own
            # dimension must not be classified on its own: `deptht(deptht)` has
            # no horizontal pair to compare against, so it would fall through to
            # the fallback and veto as if it were a horizontal axis.
            if len(array.GetDimensions()) >= 2:
                axis_units.update(NetCDF._horizontal_axis_units(array))
        units = {
            array.GetUnit().strip().lower()
            for name, array in arrays.items()
            if array.GetUnit() and name in coordinate_names
        }
        return units, axis_units

    @staticmethod
    def _coordinate_names(arrays) -> set[str]:
        """Names of every array that acts as a coordinate.

        Two ways an array qualifies: it indexes a dimension, or a data variable
        names it in a ``coordinates`` attribute -- which is how CF identifies a
        curvilinear grid's 2-D lat/lon, since those index nothing.

        Args:
            arrays: The group's MDArrays.

        Returns:
            set[str]: Lower-cased coordinate names.
        """
        names: set[str] = set()
        for array in arrays:
            referenced = NetCDF._array_attributes(array).get("coordinates", "")
            names.update(name.lower() for name in referenced.split())
            for dimension in array.GetDimensions():
                if dimension.GetIndexingVariable() is not None:
                    names.add(dimension.GetName().rsplit("/", 1)[-1].lower())
        return names

    @staticmethod
    def _array_attributes(node) -> dict[str, str]:
        """Attributes of an MDArray as a lower-cased name -> string mapping.

        ``GetAttribute(name)`` *raises* when the attribute is absent under
        ``gdal.UseExceptions()`` (which pyramids installs at import), so
        enumerate instead of asking.

        Args:
            node: A GDAL MDArray (or anything exposing ``GetAttributes``).

        Returns:
            dict[str, str]: The attributes, or ``{}`` when there are none.
        """
        collected: dict[str, str] = {}
        try:
            for attribute in node.GetAttributes():
                collected[attribute.GetName().strip().lower()] = (
                    attribute.ReadAsString()
                )
        except (RuntimeError, AttributeError):
            collected = {}
        return collected

    @staticmethod
    def _declares_vertical(indexing) -> bool:
        """Whether an indexing variable itself declares that it is a vertical axis.

        Args:
            indexing: The dimension's indexing variable, or ``None``.

        Returns:
            bool: True when it carries ``axis = "Z"``, a ``positive`` attribute,
            or a vertical ``standard_name``.
        """
        result = False
        if indexing is not None:
            attributes = NetCDF._array_attributes(indexing)
            standard_name = attributes.get("standard_name", "").strip().lower()
            result = (
                attributes.get("axis", "").strip().upper() == "Z"
                or "positive" in attributes
                or standard_name in VERTICAL_STANDARD_NAMES
            )
        return result

    @staticmethod
    def _horizontal_axis_units(array) -> set[str]:
        """Units of an array's *horizontal* dimension axes.

        GDAL's dimension type is only trustworthy in one direction. It reports
        ``HORIZONTAL_X`` / ``HORIZONTAL_Y`` on real evidence -- a declared
        ``axis`` / ``standard_name``, or degrees units -- but it *guesses*
        ``VERTICAL`` from metre units alone, so a projected x/y axis carrying
        only ``units = "m"`` comes back typed VERTICAL. Skipping on that type
        therefore discards precisely the metre veto this exists to apply.

        So: when GDAL has typed both horizontal axes, take exactly those and
        ignore every other dimension -- which excludes depth, time and model
        levels structurally, whatever they are named, including the
        ``(time, lat, lev, lon)`` layouts where the vertical axis sits between
        the horizontal ones. Otherwise GDAL could not tell, and a dimension
        counts as horizontal unless it is temporal, *declares* itself vertical,
        or is a guessed-vertical carrying a conventional vertical name.

        Args:
            array: A GDAL multidim MDArray.

        Returns:
            set[str]: Lower-cased units of the horizontal axes.
        """
        dimensions = array.GetDimensions()
        types = [(dimension.GetType() or "").upper() for dimension in dimensions]
        if "HORIZONTAL_X" in types and "HORIZONTAL_Y" in types:
            horizontal = [
                dimension
                for dimension, dim_type in zip(dimensions, types)
                if dim_type in ("HORIZONTAL_X", "HORIZONTAL_Y")
            ]
        else:
            horizontal = [
                dimension
                for dimension, dim_type in zip(dimensions, types)
                if not NetCDF._is_non_horizontal(dimension, dim_type)
            ]
        return {
            unit.strip().lower()
            for unit in (NetCDF._dimension_unit(d) for d in horizontal)
            if unit
        }

    @staticmethod
    def _is_non_horizontal(dimension, dim_type: str) -> bool:
        """Whether a dimension is temporal or vertical when GDAL could not tell.

        Args:
            dimension: A GDAL multidim dimension.
            dim_type: Its upper-cased GDAL type.

        Returns:
            bool: True for a temporal axis, one that declares itself vertical, or
            a guessed-vertical carrying a conventional vertical name.
        """
        named_vertical = (
            dim_type == "VERTICAL"
            and dimension.GetName().rsplit("/", 1)[-1].lower() in VERTICAL_AXIS_NAMES
        )
        return (
            dim_type == "TEMPORAL"
            or named_vertical
            or NetCDF._declares_vertical(dimension.GetIndexingVariable())
        )

    @staticmethod
    def _dimension_unit(dimension) -> str:
        """Unit of a dimension's indexing variable, or ``""``.

        Args:
            dimension: A GDAL multidim dimension.

        Returns:
            str: The unit, or ``""`` when the dimension indexes nothing.
        """
        indexing = dimension.GetIndexingVariable()
        return (indexing.GetUnit() if indexing is not None else "") or ""

    def _cf_geographic_crs(self) -> str:
        """WGS 84 WKT when CF metadata says this is a lat/lon grid, else ``""``.

        CF-1.x allows a data variable to carry no ``grid_mapping`` at all. When
        its coordinate axes are ``degrees_east`` / ``degrees_north`` the file
        *is* geographic — CF simply leaves the datum implicit — and GDAL's
        netCDF driver reports an empty projection for it. Reading such a grid as
        WGS 84 is a convention-backed interpretation of the metadata, not the
        blanket "assume WGS 84 for anything unprojected" default that ARC-26
        removed: a raster with no CRS *and no such evidence* still reports
        ``None``.

        Returns:
            str: WGS 84 WKT when the axis units say geographic, otherwise ``""``.
        """
        result = ""
        try:
            group = self._multidim_group()
            if group is not None and not self._is_geostationary():
                units, axis_units = self._collect_axis_units(group)
                result = cf_geographic_wkt(units, axis_units)
                # The same geometric backstop the classic path applies, so a
                # NetCDF and the byte-equivalent GeoTIFF cannot disagree and the
                # migration/concepts docs are true of both readers.
                if result and not within_lonlat_range(self._own_extent()):
                    result = ""
        except (RuntimeError, AttributeError, ValueError, TypeError):
            # See `_container_crs`: narrow so a real fault still surfaces.
            result = ""
        return result

    def _container_crs(self) -> str:
        """CRS of a multi-variable container, borrowed from its first variable.

        The root GDAL dataset of a NetCDF carries no projection of its own — the
        georeference lives on each variable — so ``GetProjection()`` returns
        ``""`` for a container even when every variable is properly
        georeferenced. Historically that empty string was silently rewritten to
        WGS84 by the base ``epsg_from_wkt`` fallback, which happened to be right
        for CF geographic grids and wrong for everything else (ARC-26).

        Resolve it honestly instead: a container's variables share one grid, so
        the first variable that reports a CRS defines the container's CRS.
        Variables with no raster plane are stepped over rather than consulted --
        a :class:`~pyramids.netcdf.LabeledArray` has no ``crs`` to borrow -- and
        they are recognised from their declaration (`_declares_raster_plane`), so
        none of them is read to find that out. The result is memoised because
        every spatial operation on the container asks for it.

        Returns:
            str: The first raster variable's CRS WKT, or ``""`` when this
            container has no variable that reports one.
        """
        cached = getattr(self, "_container_crs_cache", None)
        if cached is None:
            crs = ""
            try:
                for name in self.variable_names:
                    if not self._declares_raster_plane(name):
                        # A non-raster variable has no CRS to borrow. Skipped on
                        # its declaration, not by reading it: `get_variable`
                        # materialises, so asking it would read the whole array
                        # -- 40 MB for a 5M-element series -- to learn nothing.
                        # And skipped rather than allowed to raise, because the
                        # `except` below wraps the whole loop, so one such
                        # variable early in the list would suppress the CRS of
                        # every raster variable after it.
                        continue
                    variable = self.get_variable(name)
                    if isinstance(variable, LabeledArray):
                        continue
                    variable_crs = variable.crs
                    if variable_crs:
                        crs = str(variable_crs)
                        break
            except (RuntimeError, AttributeError, ValueError, TypeError):
                # GDAL surfaces a bad/absent group as RuntimeError; the rest guard a
                # half-built object during __init__. Deliberately NOT bare Exception:
                # that hid the initialisation-order bug this comment used to excuse.
                crs = ""
            cached = crs
            self._container_crs_cache = cached
        return cached

    def _get_crs(self) -> str:
        """Projection WKT, falling back to the variables' CRS for a container.

        ``_is_subset`` defaults to ``True`` here because ``super().__init__()``
        resolves the EPSG *before* that flag is assigned; skipping the fallback
        during construction keeps this off the half-built object, and the
        property below re-resolves it once the container is usable.

        Returns:
            str: This dataset's CRS WKT; its first variable's when this is a
            container whose root carries no projection; or WGS 84 when CF
            metadata identifies a lat/lon grid that declares no ``grid_mapping``.
        """
        cached: str | None = getattr(self, "_crs_cache", None)
        if cached is not None:
            return cached
        crs = super()._get_crs()
        if not crs and not getattr(self, "_is_subset", True):
            crs = self._container_crs()
        if not crs:
            # The file's own axes come first: CF degrees axes with no
            # grid_mapping mean a geographic grid (see `_cf_geographic_crs`).
            crs = self._cf_geographic_crs()
        if not crs:
            # Then honour the CRS pyramids itself writes. `to_netcdf` records it
            # as NC_GLOBAL `crs_wkt` / `epsg` rather than a grid_mapping, so a
            # round-trip would otherwise come back ungeoreferenced. Ranked below
            # the axis evidence so a recorded attribute cannot override what the
            # file's own coordinates say.
            crs = self._crs_from_global_attrs()
        # Memoise: resolution walks the container's variables and every MDArray's
        # units, and `crs_spec(epsg, crs)` evaluates BOTH arguments (unlike the
        # `epsg or crs` idiom it replaced, which short-circuited), so `.crs` is
        # now read on every spatial call. Cleared wherever `_container_crs_cache`
        # is, since it derives from the same evidence.
        self._crs_cache = crs
        return crs

    @property
    def epsg(self) -> int | None:
        """EPSG code, resolving a container's code from its variables.

        The base class caches ``_epsg`` during construction, but both of this
        class's CRS fallbacks resolve *lazily* — a container borrows its CRS
        from a variable, and a CF lat/lon grid infers WGS 84 from its axis units
        — and neither is available on the half-built object. So a cached
        ``None`` is not authoritative; re-derive it from :attr:`crs`, which runs
        those fallbacks, and only then report an absent CRS.

        Returns:
            int | None: The EPSG code, or ``None`` when there is no CRS at all
            (or the CRS carries no EPSG authority, e.g. geostationary).
        """
        code = super().epsg
        if code is None and not getattr(self, "_epsg_resolved", False):
            # `None` is a legitimate answer *and* the unresolved state, so a bare
            # write-back never sticks: a CRS-less container re-ran the full scan
            # on every read. Track resolution with its own flag.
            #
            # Re-derive through `_get_epsg`, not `epsg_of_crs(self._get_crs())`:
            # the geostationary guard lives there, and going straight to the CRS
            # would relabel a scan-angle grid as WGS 84 (#706).
            code = self._get_epsg()
            self._epsg_resolved = True
            # Write the answer back. Resolution walks every MDArray (one
            # `OpenMDArray` per variable) and is on the hot path for spatial ops,
            # so leaving `_epsg` unset made each `.epsg` read repeat the whole
            # scan. `_update_inplace` clears the cache, so a raster swap
            # re-resolves.
            self._epsg = code
        return code

    @epsg.setter
    def epsg(self, value: int | str | None) -> None:
        """Set the EPSG code.

        Re-declared because overriding the getter would otherwise drop the
        inherited setter and make :attr:`epsg` read-only on ``NetCDF``.

        Args:
            value: EPSG code (or any spec `sr_from_epsg` accepts) to stamp on
                the dataset.
        """
        # The base `epsg` is a property object, so its setter must be invoked
        # explicitly rather than through `super()`. Resolve it off the MRO
        # rather than naming `Dataset`, so an intermediate class that overrides
        # the setter is not skipped.
        super(NetCDF, type(self)).epsg.fset(self, value)  # type: ignore[attr-defined]
        # Both derived caches are now stale: the borrowed container CRS, and the
        # memoised resolution flag that would otherwise pin the old answer.
        self._container_crs_cache = None
        self._crs_cache = None
        self.__dict__.pop("_cf_crs_cache", None)
        self._epsg_resolved = False

    def _get_epsg(self) -> int | None:
        """EPSG code, or ``None`` for a geostationary CRS.

        A geostationary (GOES / Himawari / MTG) fixed-grid projection is a
        custom CRS with **no EPSG authority code**, so resolving one would
        mislabel the non-geographic scan-angle grid as WGS84 (issue #706).
        Report ``None`` instead so callers read :attr:`crs` (the geostationary
        WKT); reprojection is unaffected because :meth:`to_crs` warps from the
        WKT, not the EPSG code.

        Scope: geostationary detection lives on ``NetCDF`` (where these grids are
        read from), so a geostationary raster opened as a plain ``Dataset`` (e.g.
        translated to GeoTIFF) still reports the base ``4326`` — out of scope here.

        Returns:
            int | None: The EPSG code, or ``None`` when the CRS is the CF
            geostationary projection.
        """
        if self._is_geostationary():
            return None
        return super()._get_epsg()

    def _classic_geotransform(self) -> tuple[float, ...] | None:
        """Metre geotransform from GDAL's classic netCDF driver for this var.

        The classic ``NETCDF:<file>:<var>`` driver georeferences CF
        geostationary files correctly — it applies the ``x`` / ``y``
        ``scale_factor`` / ``add_offset`` (real GOES stores them as packed
        ``int16`` scan angles) and scales the radians to projected metres by
        ``perspective_point_height``. The multidimensional ``AsClassicDataset``
        path this cube comes from does neither, so it yields a raw pixel or
        radian geotransform.

        Returns:
            tuple | None: The classic-driver geotransform, or ``None`` when
            there is no classic-openable source (a path-less in-memory ``MEM``
            dataset with no ``_vsimem_path`` and an empty ``file_name`` — a
            ``/vsimem`` source, including a ``from_bytes`` read given a cosmetic
            ``name`` that shadows ``file_name``, is openable and is rescaled,
            #1050) or the classic open does not produce a metre-scale
            geostationary geotransform.
        """
        parent = self._parent_nc
        var = self._source_var_name
        if parent is None or var is None:
            return None
        # Resolve once per variable and memoise on the parent: every spatial variable
        # would otherwise re-open `NETCDF:<file>:<var>` to recompute the same metre
        # geotransform. The cached value (a geotransform tuple or ``None``) is reused on
        # subsequent accesses of the same variable.
        cache = parent._geostationary_gt_cache
        if var in cache:
            return cache[var]

        result: tuple[float, ...] | None = None
        # The classic netCDF driver reopens NETCDF:"<path>":<var> to rescale the scan-angle
        # coordinates to projected metres; it needs a path GDAL can open. `from_bytes` records the
        # real backing `/vsimem/...` path in `_vsimem_path`, while `file_name` may instead hold a
        # caller-supplied cosmetic `name=` label — so prefer `_vsimem_path` to rescale a *named*
        # in-memory read too (an on-disk read has no `_vsimem_path` and uses `file_name`, the real
        # path). A `/vsimem/` path *is* openable (the driver reads the in-memory file directly —
        # confirmed cross-platform); only a truly path-less MEM dataset (empty `file_name`, no
        # `_vsimem_path`) is skipped. Excluding `/vsimem` here silently dropped the metre
        # geotransform for every `from_bytes()` geostationary read — the only in-memory NetCDF
        # reader on Windows/macOS — leaving a raw scan-angle geotransform under the metre CRS
        # (#1050). A remote/archive path the netCDF driver cannot open raises below and falls back
        # to `None`.
        path = getattr(parent, "_vsimem_path", None) or parent.file_name
        if path:
            try:
                src = gdal.Open(f'NETCDF:"{path}":{var}')
            except RuntimeError:
                src = None
            if src is not None:
                gt = src.GetGeoTransform()
                srs = src.GetSpatialRef()
                if (
                    srs is not None
                    and srs.GetAttrValue("PROJECTION") == GEOSTATIONARY_PROJECTION
                    and abs(gt[1]) > 1.0
                ):
                    result = gt

        cache[var] = result
        return result

    def _geolocation_source(self) -> Dataset:
        """Reopen the classic ``NETCDF`` handle, which carries the ``GEOLOCATION`` domain.

        Geolocation arrays are exposed by GDAL's classic ``NETCDF:<file>:<var>``
        driver, but the multidimensional ``AsClassicDataset`` view a NetCDF variable
        comes from drops that domain. Reopen the classic handle as a **base**
        :class:`~pyramids.dataset.Dataset` (so no multidimensional re-parse happens)
        when there is a real on-disk / VSI source and a variable name; otherwise fall
        back to ``self`` — which then yields the generic "no geolocation arrays"
        error (correct for a container: extract a variable first).

        Returns:
            Dataset: a base ``Dataset`` over the classic handle, or ``self``.
        """
        # Memoise the reopened handle (the classic source is a stable file property)
        # so a common `if var.has_geolocation: var.geolocate(...)` flow does not
        # reopen `NETCDF:<file>:<var>` two or three times, mirroring the memoisation
        # of the sibling `_classic_geotransform`. `_update_inplace` clears it.
        source = self.__dict__.get("_geolocation_source_memo")
        if source is None:
            var = self._source_var_name
            parent = self._parent_nc
            source_obj = parent if parent is not None else self
            # Prefer `_vsimem_path` (the real backing `/vsimem/...` path that a cosmetic `from_bytes`
            # `name=` shadows in `file_name`) and admit `/vsimem` — the classic driver opens it
            # directly (confirmed cross-platform by #1050) — so an in-memory (`from_bytes`) swath
            # exposes its GEOLOCATION domain exactly like an on-disk read (#1053), mirroring the
            # sibling `_classic_geotransform`. Only a truly path-less MEM source (no `_vsimem_path`,
            # empty `file_name`) or a container with no variable falls through to `self`.
            path = getattr(source_obj, "_vsimem_path", None) or source_obj.file_name
            handle = None
            if var is not None and path:
                try:
                    handle = gdal.Open(f'NETCDF:"{path}":{var}')
                except RuntimeError:
                    handle = None
            source = self if handle is None else Dataset(handle, access="read_only")
            # Only cache a genuinely reopened handle; caching the `self` fallback
            # would create a pointless self-reference and re-resolving it is cheap.
            if source is not self:
                self.__dict__["_geolocation_source_memo"] = source
        return source

    def _normalize_geostationary_geotransform(self) -> None:
        """Georeference a geostationary variable read via the MDIM path.

        Real GOES (and other CF geostationary) files store ``x`` / ``y`` as
        scan angles that the classic netCDF driver scales to projected metres by
        ``perspective_point_height`` (after applying their ``scale_factor`` /
        ``add_offset``). The multidimensional ``AsClassicDataset`` path used by
        :meth:`get_variable` does neither — ``GDALMDArray::GuessGeoTransform``
        reads the *raw* coordinate values — so the cube comes back with a raw
        pixel/radian geotransform under a metre-based geostationary CRS and
        ``to_crs`` collapses. Adopt the classic driver's metre geotransform so
        the cube is correctly georeferenced; a no-op for every other CRS.

        An ``AsClassicDataset`` view has no driver and silently ignores
        ``SetGeoTransform``, so the corrected geotransform is stamped onto a
        materialized ``MEM`` raster (see :meth:`_materialize_md_view`), which
        every downstream ``Warp`` / ``CreateCopy`` then sees.

        Side effects for a rescaled geostationary cube:

        * ``self.raster`` becomes a MEM raster with no MDIM root group, so
          coordinate accessors (`lon` / `lat` / `x` / `y`) report the projected
          **metre** coordinates derived from the geotransform, not the raw
          ``x`` / ``y``.
        * ``crop(bbox=...)`` against the cube's own geostationary CRS can fail
          inside PROJ (off-disc cutline). Reproject with ``to_crs(4326)`` and
          crop the result.
        """
        if not self._is_geostationary():
            return
        correct = self._classic_geotransform()
        if correct is None:
            return
        if self._geotransform == correct:
            # Already georeferenced (e.g. opened via the classic read path).
            self._geostationary_scaled = True
            return
        # Adopt the classic driver's metre geotransform and stamp it onto a materialized MEM raster.
        # The previous approach wrapped the view in a VRT purely because an AsClassicDataset view
        # ignores SetGeoTransform -- but every read and warp then went through that VRT, which was
        # ~20x slower than reading the view directly and, over a Y-reversed view, raised
        # "arrayStartIdx[...] >= <dim>" on each windowed block.
        # `_classic_geotransform` returns GDAL's 6-element affine as an unsized `tuple[float, ...]`.
        self._derived_geotransform = None
        self._geotransform = cast(
            "tuple[float, float, float, float, float, float]", correct
        )
        # The classic driver describes the grid as *it* stores it: it flips a bottom-up Y but never
        # reverses X, emitting a negative gt[1] instead. Re-anchor the adopted affine for whichever
        # axes _read_md_array actually reversed, or the metre grid would describe the pre-flip array
        # and mirror it. A no-op for every real granule, whose scaled X ascends and scaled Y descends.
        self._correct_flipped_geotransform(self)
        self._cell_size = GeoTransform(*self._geotransform).cell_size
        with warnings.catch_warnings():
            # _materialize_md_view warns generically on failure. Here the consequence is specific
            # and worse -- the wrapper claims metres over a raw scan-angle grid -- so own the
            # message rather than emitting two overlapping warnings for one failure.
            warnings.filterwarnings(
                "ignore", message="could not materialize the multidim view"
            )
            self._materialize_md_view()
        if not self._md_view_materialized:
            warnings.warn(
                "could not materialize the geostationary view; the wrapper "
                "geotransform reports metres but the underlying dataset keeps "
                "its raw scan-angle grid, so to_crs/crop may be wrong.",
                stacklevel=3,
            )
        self._geostationary_scaled = True

    @property
    def variable_names(self) -> list[str]:
        """Names of data variables (excluding dimension coordinate arrays).

        **Order is the store's, not alphabetical.** This previously reported
        the CF classification's own list, which is sorted, so every
        multi-variable container came back alphabetically whatever order the
        file declared -- a store declaring `z` then `q` reported
        `["q", "z"]`. It now filters the declared list, so the answer is
        `["z", "q"]`. The *set* of names is unchanged; only the order is, and
        nothing warns.

        It is worth knowing about because the order is load-bearing:
        `_fan_out_eager` templates a container-wide result from the **first**
        spatial variable, taking its geotransform, CRS, no-data and extra
        dimensions, so a container-wide `to_crs` / `resample` now templates
        from a possibly different variable -- and the order propagates into
        `to_netcdf` and `to_xarray`. Call `sorted(nc.variable_names)` where the
        old order matters. See `docs/migration.md`, netcdf / unreleased.

        Returns:
            list[str]: Variable names, in the order the store declares them.
            For MDIM mode these come from `GetMDArrayNames()` minus dimension
            names; for classic mode from `GetSubDatasets()`.
        """
        return self._get_variable_names()

    @property
    def data_vars(self) -> _LazyVariableDict:
        """xarray's name for :attr:`variables` — a **mapping**, as xarray's is.

        Deliberately not an alias of :attr:`variable_names`, which is a *list*, for the
        reason :attr:`dims` gives at length: aliasing a mapping name onto a list is the
        `variables`-shaped collision this class is trying to avoid, and `data_vars` is the
        spelling a reader arriving from xarray reaches for first. `ds.data_vars["t2m"]` and
        `ds.data_vars.items()` both work in xarray, and both work here.

        Iterating it still yields the names, so `list(nc.data_vars)` and `len(nc.data_vars)`
        read the same as they would against a list.

        **Read-through, not writable.** `nc.data_vars["x"] = ...` raises `TypeError`: the
        mapping caches what it has loaded, so a write would be visible to `nc["x"]` and
        invisible to `in`, `len` and iteration. Use `add_variable` / `set_variable` /
        `remove_variable`.

        **Differs from xarray** in what the values are: each is a `NetCDF` subset or a
        `LabeledArray`, where xarray's are `DataArray`s.

        Returns:
            _LazyVariableDict: Name to variable, loading each on first access.

        Examples:
            - Iterating yields the names, as it does in xarray:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> list(nc.data_vars)
              ['area', 'msk_rgn', 'pr', 'tas', 'ua']
              >>> len(nc.data_vars)
              5

              ```
            - Indexing reaches the variable, which is what the list form could not do:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> nc.data_vars["ua"].band_count
              17

              ```
            - So does `items()`, for a pass over names and variables together:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> [name for name, var in nc.data_vars.items() if var.band_count > 1]
              ['ua']

              ```

        See Also:
            NetCDF.variables: The canonical member.
            NetCDF.variable_names: The names alone, as a list.
            NetCDF.dtypes: The same names, mapped to their types.
        """
        return self.variables

    @property
    def dims(self) -> dict[str, int]:
        """Dimension name to length — a **mapping**, as xarray's `Dataset.dims` is.

        Deliberately not an alias of :attr:`dimension_names`, which is a *list*. Aliasing a
        mapping name onto a list would be a second `variables`-shaped collision: a reader
        coming from xarray writes `nc.dims["time"]` and would silently get a list index.
        The list keeps its own name; this returns what the xarray spelling promises, which
        makes it an alias of :attr:`dimension_sizes` — the same mapping under xarray's name
        for it, built afresh on every call rather than handed out as one shared object.

        **Differs from xarray** in two ways, and the second is a moving target:

        - It is a plain `dict` where xarray hands back a frozen mapping, so a write —
          `nc.dims["lat"] = 1` — succeeds against that throwaway and is silently discarded
          rather than raising. Change an axis through the store, not through this.
        - **xarray is deprecating `Dataset.dims` as a mapping.** As of 2026.7.0 it returns
          a `FrozenMappingWarningOnValuesAccess` that warns when the *values* are read, on
          its way to returning a set of dimension *names*. When that lands, the member this
          one is named after will be closer to :attr:`dimension_names` than to this.
          :attr:`sizes` is the spelling that stays a name-to-length mapping on both sides,
          and is the one to reach for in code meant to last.

        This member is not following it: `dims` as a mapping is what a reader arriving
        today expects, and matching a set of names would bring straight back the collision
        the name was chosen to avoid.

        Inherits :attr:`dimension_sizes`' contract on a **variable subset**, where it is `{}`:
        a subset has no root group to enumerate dimensions from. :attr:`dimension_names` falls
        back to the names cached when the subset was built and so still reports them, which is
        the one place the two disagree about more than their shape.

        A **classic** container (`open_as_multi_dimensional=False`) has no root group either,
        so it is `{}` there too however many variables the store holds — and there
        :attr:`dimension_names` has no cache to fall back on and answers `None`.

        Returns:
            dict[str, int]: Dimension name to its length. `{}` on a variable subset and on a
            classic container.

        Examples:
            - Keys are the names, values the lengths:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.dims["time"], sorted(nc.dims) == sorted(nc.dimension_names)
              (4, True)

              ```
            - A variable subset reports none, while its names survive:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc["temperature"].dims
              {}
              >>> nc["temperature"].dimension_names[:2]
              ['time', 'pressure_level']

              ```

        See Also:
            NetCDF.dimension_sizes: The canonical member, and where the subset rule comes from.
            NetCDF.dimension_names: The names alone, as a list.
        """
        return self.dimension_sizes

    @property
    def sizes(self) -> dict[str, int]:
        """xarray's other name for :attr:`dimension_sizes`. Read-only alias.

        **Differs from xarray** in the same way :attr:`dims` does: a plain `dict`, not a
        `Frozen`, so a write is silently discarded rather than refused. It is also `{}`
        wherever `dims` is — on a variable subset and on a classic container, neither of
        which has a root group to enumerate dimensions from.

        Returns:
            dict[str, int]: Dimension name to its length. `{}` on a variable subset and on a
            classic container.

        Examples:
            - Look up one axis's length:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> nc.sizes["plev"], nc.sizes["lat"]
              (17, 128)

              ```
            - Size the grid a variable is defined on:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.sizes["lat"] * nc.sizes["lon"]
              30

              ```

        See Also:
            NetCDF.dimension_sizes: The canonical member.
            NetCDF.dims: The same mapping, under xarray's other name for it.
        """
        return self.dimension_sizes

    @property
    def attrs(self) -> dict[str, Any]:
        """xarray's name for :attr:`global_attributes`. Read-only alias.

        On a **classic** container (`open_as_multi_dimensional=False`) there is no root group
        to read, and :attr:`global_attributes` falls back to GDAL's `GetMetadata()`. What
        comes back then is not the root group's attribute list: the global attributes are
        prefixed (`NC_GLOBAL#Conventions`), every variable's own attributes are folded in
        under their own prefix (`temperature#units`), and GDAL's synthetic `NETCDF_DIM_*`
        entries are added. Key by the prefixed spelling there, or open the store in MDIM mode.

        Returns:
            dict[str, Any]: The root group's attributes, or GDAL's whole metadata dictionary
            on a classic container.

        Examples:
            - Read the root group's attributes:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.attrs
              {'Conventions': 'CF-1.6'}

              ```
            - Ask a busier store which convention it follows:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> nc.attrs["Conventions"]
              'CF-1.0'
              >>> len(nc.attrs) > 10
              True

              ```
            - The same store read classically answers under GDAL's prefixed keys instead:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file(
              ...     "tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc",
              ...     open_as_multi_dimensional=False,
              ... )
              >>> nc.attrs["NC_GLOBAL#Conventions"], nc.attrs["temperature#units"]
              ('CF-1.6', 'K')

              ```

        See Also:
            NetCDF.global_attributes: The canonical member, and the setters
                `set_global_attribute` / `delete_global_attribute`.
        """
        return self.global_attributes

    @property
    def coords(self) -> dict[str, np.typing.NDArray]:
        """Every dimension's stored coordinate, as `{name: ndarray}`.

        Built from :meth:`get_dimension_values`, so the storage-order contract lives in one
        place: a **spatial** axis comes back as the file stores it, which for a
        south-to-north file is the reverse of the raster's north-up rows. A dimension with
        no indexing variable is omitted rather than mapped to `None`.

        The keys come from :attr:`dimension_names`, **not** from :attr:`dims` — on a variable
        subset `dims` is `{}` while the names survive, so reading `dims` here would drop the
        axes a subset can still report. `set(nc.coords) <= set(nc.dimension_names)` is
        therefore the invariant, and it is the one to rely on. The subset inclusion is strict
        in practice, and by more than one axis: a subset tracks only its **band** dimensions,
        so *every* spatial axis answers `None` from :meth:`get_dimension_values` and drops out
        — the x axis as much as the y, and whether or not either was renamed. A subset of a
        `(time, level, lat, lon)` cube therefore reports `time` and `level` only. The renaming
        of the y axis to the window it was cut with (`subset_lat_4_-1_5`) is real but
        incidental; a store whose subset keeps both axis names loses both coordinates just the
        same. A spatial axis's cell centres come from the geotransform instead, via
        :meth:`~pyramids.dataset.transform.GeoTransform.x_axis` /
        :meth:`~pyramids.dataset.transform.GeoTransform.y_axis`.

        A **classic** container (`open_as_multi_dimensional=False`) has no dimension names at
        all — :attr:`dimension_names` is `None` there, not a list, so the invariant above has
        nothing to hold over and this answers `{}` however many variables the store enumerates.

        Values are read when this is called, one array per dimension; the arrays are small
        (one per axis, not per cell) but this is not free.

        **Differs from xarray**, whose `coords[name]` is a `DataArray` carrying its own
        `dims` and `attrs`. These are bare arrays: `nc.coords["lat"].values` is an
        `AttributeError`, because the array *is* the values. They are also **undecoded**: a
        CF time axis stays the raw offsets its `units` count, not `datetime64`.

        Returns:
            dict[str, numpy.ndarray]: Dimension name to its stored coordinate. `{}` on a
            classic container.

        Examples:
            - Read the axes the store actually indexes:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> sorted(nc.coords)
              ['lat', 'lon', 'pressure_level', 'time']
              >>> nc.coords["lat"].tolist()
              [40.0, 41.0, 42.0, 43.0, 44.0]
              >>> nc.coords["time"].tolist()
              [0.0, 6.0, 12.0, 18.0]

              ```
            - Find which index a label sits at, which is what the mapping is for:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.coords["pressure_level"].tolist().index(850.0)
              1

              ```
            - A dimension with no indexing variable is absent, not `None`:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> "bnds" in nc.dims, "bnds" in nc.coords
              (True, False)

              ```
            - A subset keeps only its band axes. Here neither spatial axis was renamed, and
              both drop out all the same:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__20v__1d3-3d17__y-desc.nc")
              >>> subset = nc["tcw"]
              >>> subset.dimension_names
              ['time', 'latitude', 'longitude']
              >>> sorted(subset.coords)
              ['time']
              >>> subset.get_dimension_values("longitude") is None
              True

              ```

        See Also:
            NetCDF.get_dimension_values: The per-name accessor this reads.
            NetCDF.to_xarray: Which *decodes* a CF time axis, where this does not.
        """
        values = {}
        for name in self.dimension_names or []:
            stored = self.get_dimension_values(name)
            if stored is not None:
                values[name] = stored
        return values

    @property
    def dtypes(self) -> dict[str, str]:
        """Each data variable's dtype, as `{name: dtype}`.

        One entry per **distinct** name in :attr:`variable_names` — a list that a classic
        container can repeat, as the "Differs from xarray" note below spells out — so a
        variable subset, which enumerates none, reports `{}`.

        **No data variable's array is read.** For a raster variable the type comes from the
        band description. Opening one does read its *coordinate* axes — one small array per
        dimension, never one per cell — which is what "reads no pixels" means here.

        A variable with **no raster plane** is the exception: it comes back as a
        `LabeledArray`, and building one materialises the variable's own array. So on a
        store of 1-D or non-numeric variables this reads each of them, and the arrays stay
        in the :attr:`variables` cache afterwards. The type reported is then the
        materialised array's, which for a string variable is `'object'` rather than any
        type the store uses.

        A name the store enumerates but will not open reports `"unknown"` rather than
        raising. That happens on a **classic** container, whose subdataset enumeration can
        report an array GDAL then declines to open; introspection describes what it can
        instead of stopping at the first such name.

        **Differs from xarray** in two ways: the values are `str`, not `numpy.dtype`
        objects, so `nc.dtypes["t2m"] == np.float64` is `False` where xarray's is `True` —
        compare `np.dtype(nc.dtypes["t2m"])` instead. And being a mapping it is keyed by
        **unique** name, while :attr:`variable_names` is a list that a classic container
        can repeat: on `cf__40v__1d28-2d9-3d3__nc4.nc` opened classically that list has 12
        entries and 9 distinct names, so `len(nc.dtypes)` is 9 while `len(nc)` is 12.

        Returns:
            dict[str, str]: Variable name to the dtype of its first band, or to the
                `LabeledArray`'s own dtype for a variable with no raster plane.

        Warns:
            UserWarning: Once per name reported `"unknown"`, naming it and the refusal
                behind it. Once per *distinct* name, not once per occurrence — the walk is
                over `dict.fromkeys(variable_names)`, so a classic container that enumerates
                the same name twice still warns about it once.

        Examples:
            - One entry per data variable:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> nc.dtypes["ua"], nc.dtypes["msk_rgn"]
              ('float32', 'int32')

              ```
            - Pick out the variables that are not floating point:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> [name for name, kind in nc.dtypes.items() if "int" in kind]
              ['msk_rgn']

              ```
            - Read classically, the same store types the names GDAL will not open `unknown`
              and carries on with the rest:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file(
              ...     "tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc",
              ...     open_as_multi_dimensional=False,
              ... )
              >>> nc.dtypes["precipitation_flux"], nc.dtypes["area"]
              ('unknown', 'float32')

              ```

        See Also:
            NetCDF.nbytes: Sized from these dtypes and the dimension lengths.
            NetCDF.variable_names: The names these are keyed by.
        """
        # A plain loop over distinct names, not a comprehension over the raw list. The list
        # can repeat a name on a classic container, which made this re-open — and re-warn
        # about — the same unopenable variable once per occurrence; and the generator frame
        # a comprehension adds pushed `_open_variable`'s warning off the caller's line and
        # onto this file's.
        types: dict[str, str] = {}
        for name in dict.fromkeys(self.variable_names):
            variable = _open_variable(self, name)
            types[name] = "unknown" if variable is None else _variable_dtype(variable)
        return types

    @property
    def nbytes(self) -> int:
        """The size of every data variable in bytes, computed rather than read.

        From each variable's shape and dtype. **Data variables only** — xarray's `nbytes`
        counts its coordinates too, so the two differ by the size of the coordinate arrays.

        Three things to know before trusting the number:

        - A **raster** variable is sized without reading a single cell, which is the point:
          a cube far larger than memory can still be sized. Its coordinate axes are read —
          one small array per dimension — because opening a variable resolves them.
        - A variable with **no raster plane** is a `LabeledArray`, and building one
          materialises its array — so those are read, not computed. A string variable is
          then counted as 8 bytes per Python object pointer, which bears no relation to
          the characters stored.
        - A **packed** variable is sized by its *stored* band type, not by what
          :meth:`read_array` returns. Reading an `int16` store that unpacks to `float64`
          costs four times this figure, not one.

        See :attr:`dtypes` for the same caveats on the types.

        It sizes what :attr:`variable_names` enumerates, which on a **variable subset** is
        nothing: `nc["t2m"].nbytes` is `0`, not that variable's own size. Size a single
        variable from its shape and :attr:`dtype` instead.

        Sized over **distinct** names, so this and :attr:`dtypes` agree on what "the data
        variables" are: a classic container's enumeration can report the same name twice,
        and summing the list would count that variable twice.

        A name the store enumerates but will not open — see :attr:`dtypes` — contributes
        `0` and a `UserWarning` naming it. The shortfall can be large rather than marginal:
        a **classic** container's subdataset list can name a variable by its `standard_name`
        (`precipitation_flux` for `pr`), which GDAL then declines to open, so the three
        largest cubes of `cf__12v__1d4-2d5-3d2-4d1__y-asc.nc` contribute nothing and the
        total is 9.7% of the real size. Open the store multidimensionally to size it.

        Returns:
            int: The total, `0` for a container with no data variables.

        Examples:
            - A 12-band 5x6 float64 cube is 2880 bytes:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.nbytes
              2880

              ```
            - Decide whether a store is worth reading, without reading it:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> round(nc.nbytes / 1024**2, 2)
              2.62

              ```

        See Also:
            NetCDF.dtypes: The types this sizes from.
            NetCDF.dims: The lengths it sizes over.
        """
        total = 0
        unsized = []
        # `dict.fromkeys` rather than the list: a classic container's enumeration can
        # report a name twice, and `dtypes` is keyed uniquely. Summing the list would size
        # the repeat twice and make the two members disagree about what "the data
        # variables" are. Order is preserved, which `set` would not do.
        for name in dict.fromkeys(self.variable_names):
            variable = _open_variable(self, name)
            if variable is None:
                unsized.append(name)
            else:
                total += _variable_nbytes(variable)
        if unsized:
            # Silence here would be the worst of the three options. The shortfall is not
            # marginal: on `cf__12v__1d4-2d5-3d2-4d1__y-asc.nc` opened classically the
            # enumeration names the three real cubes by `standard_name`
            # (`precipitation_flux` for `pr`), none of which GDAL will open, so the total
            # comes to 9.7% of the true figure while looking like an ordinary answer.
            warnings.warn(
                f"nbytes excludes {len(unsized)} variable(s) the store enumerates but "
                f"will not open, so the total is lower than the real size: "
                f"{sorted(unsized)}. This happens on a classic container, whose "
                f"subdataset list can name an array by its standard_name; open the store "
                f"with open_as_multi_dimensional=True to size it properly.",
                UserWarning,
                stacklevel=2,
            )
        return total

    def info(self, buf: TextIO | None = None) -> None:
        """Print a summary of the container: dimensions, variables and attributes.

        Calls :attr:`dtypes`, so it inherits that member's caveat: a variable with no
        raster plane is materialised to report its type.

        On a **grouped** store the `dimensions:` section lists only the root group's, which
        is what :attr:`dimension_sizes` reports, while the variable lines reference each
        group's own axis of the same name — `none__35v__1d35__groups-nc4.nc` declares one
        `recNum` and then names `recNum` on 29 variables whose lengths differ. Faithful to
        the members it prints, but not something `ncdump -h` would emit.

        A **classic** container (`open_as_multi_dimensional=False`) has no multidim group
        behind it, and three parts of the report thin out as a result:

        - Every variable is printed with an **empty axis list**, because the per-variable
          dimension names are read from that group.
        - The `dimensions:` section is **empty** — :attr:`dimension_sizes` is enumerated from
          the same group — so `variables:` follows it immediately, however many variables the
          store holds.
        - A name the classic subdataset list reports but GDAL declines to open is typed
          `unknown`, from :attr:`dtypes`, rather than taking the whole summary down.

        The attribute block grows rather than thins: :attr:`attrs` falls back to GDAL's
        `GetMetadata()` there, so a global attribute is spelled `:NC_GLOBAL#Conventions = …`
        and every variable's own attributes (`:temperature#units = …`) and GDAL's synthetic
        `NETCDF_DIM_*` entries are listed alongside them.

        Args:
            buf: An open text stream to write to. Defaults to `sys.stdout`, matching
                xarray's `info`.

        Warns:
            UserWarning: Once per name typed `unknown`, raised through :attr:`dtypes`. A
                summary that reports three `unknown` lines has warned three times, so a
                caller filtering warnings rather than reading the report still learns the
                store's enumeration is unreliable.

        Examples:
            - The whole summary of a small store. The real output indents with tabs, as
              `ncdump -h` does; they are expanded here so the example reads:

              ```python
              >>> import io
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> report = io.StringIO()
              >>> nc.info(report)
              >>> print(report.getvalue().expandtabs(4), end="")
              pyramids.NetCDF {
              dimensions:
                  time = 4 ;
                  pressure_level = 3 ;
                  lat = 5 ;
                  lon = 6 ;
              variables:
                  float64 temperature(time, pressure_level, lat, lon) ;
              <BLANKLINE>
              // global attributes:
                  :Conventions = 'CF-1.6' ;
              }

              ```
            - Capture it instead of printing, to search or log it:

              ```python
              >>> import io
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> report = io.StringIO()
              >>> nc.info(report)
              >>> [line.strip() for line in report.getvalue().splitlines() if "ua(" in line]
              ['float32 ua(time, plev, lat, lon) ;']

              ```
            - The same store read classically: no dimension lines, and no axes on a variable:

              ```python
              >>> import io
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file(
              ...     "tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc",
              ...     open_as_multi_dimensional=False,
              ... )
              >>> report = io.StringIO()
              >>> nc.info(report)
              >>> lines = report.getvalue().splitlines()
              >>> [line.expandtabs(4) for line in lines[:4]]
              ['pyramids.NetCDF {', 'dimensions:', 'variables:', '    float32 area() ;']
              >>> [line.strip() for line in lines if "precipitation_flux" in line]
              ['unknown precipitation_flux() ;']

              ```

        See Also:
            NetCDF.dtypes: The per-variable types this prints.
            NetCDF.dims: The dimension lengths it prints.
        """
        stream = sys.stdout if buf is None else buf
        # `None` on a classic container, which has no multidim group to read axes from;
        # `_variable_dim_names` would reach `None.OpenMDArray` and raise `AttributeError`.
        rg = self._working_group()
        types = self.dtypes
        # The names come from `types`, which `dtypes` has just built from its own walk.
        # Reading `variable_names` again would re-query `GetMDArrayNames()` and re-run CF
        # classification — the expensive half of the summary on a large store — and any
        # disagreement between the two walks would surface as a bare `KeyError` from the
        # `types[name]` below, out of a read-only method. `dtypes` is already keyed by
        # distinct name, so this is distinct too.
        names = list(types)
        lines = ["pyramids.NetCDF {", "dimensions:"]
        lines += [f"\t{name} = {size} ;" for name, size in self.dimension_sizes.items()]
        lines.append("variables:")
        for name in names:
            # The variable's own axes, read from the store rather than from the subset
            # `get_variable` hands back: a subset renames its y dimension to the window it was
            # cut with (`subset_lat_4_-1_5`), which is not a name this file has.
            axes = "" if rg is None else ", ".join(self._variable_dim_names(rg, name))
            lines.append(f"\t{types[name]} {name}({axes}) ;")
        lines.append("")
        lines.append("// global attributes:")
        # An attribute value is arbitrary text: a ROMS store carries a `CPP_options` over
        # 1,000 characters and an `NLM_LBC` full of newlines, either of which destroys the
        # `ncdump -h` shape this is imitating. Collapsed to one line and truncated.
        lines += [
            f"\t:{key} = {_summarised(value)} ;" for key, value in self.attrs.items()
        ]
        lines.append("}")
        print("\n".join(lines), file=stream)

    def get(self, name: str, default: Any = None) -> Any:
        """The variable called `name`, or `default` when the container has no such variable.

        Args:
            name: A data-variable name.
            default: What to return when `name` is absent. Defaults to `None`.

        Returns:
            NetCDF | LabeledArray | Any: The variable — a `NetCDF` subset or a
            `LabeledArray` — or `default` when there is no such name. Annotated `Any`
            because `default` is unconstrained; a checker cannot narrow it further.

        Raises:
            RuntimeError: `name` **is** one of the data variables but GDAL refuses to open
                it. Only a **classic** container reaches this — its subdataset enumeration
                can report an array that will not open — and `default` does not cover it, so
                a name being present is not on its own a guarantee of a value. :attr:`dtypes`
                and :attr:`nbytes` do swallow the refusal; this does not.

        Examples:
            - A miss returns the default rather than raising:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.get("nope", "absent")
              'absent'

              ```
            - A hit returns the variable, so it can be used straight away:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.get("temperature").band_count
              12

              ```
            - With no default, a miss is `None` — enough to branch on:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.get("nope") is None
              True

              ```

        See Also:
            NetCDF.__getitem__: The same lookup, raising `KeyError` on a miss.
        """
        return self.variables.get(name, default)

    def __getitem__(self, name: str) -> NetCDF | LabeledArray:
        """The variable called `name` — `nc["t2m"]`, the first thing an xarray user tries.

        Delegates to :attr:`variables`, so it is the same lazy, cached object
        :meth:`get_variable` returns and the refusal message is the one that mapping
        already gives.

        Args:
            name: A data-variable name, as :attr:`variable_names` lists it.

        Returns:
            NetCDF | LabeledArray: A `NetCDF` subset for a raster variable, a
            `LabeledArray` for one with no raster plane.

        Raises:
            KeyError: `name` is not a data variable. `get_variable` raises `ValueError` for
                the same miss; the mapping protocol has to raise `KeyError` for `in`, `get`
                and `dict(nc)` to behave, so the two spellings differ deliberately here.
            RuntimeError: `name` is enumerated but GDAL refuses to open it. Reachable on a
                **classic** container, whose subdataset list can report an array that is not
                there. :attr:`dtypes` and :attr:`nbytes` swallow that refusal so introspection
                survives it; indexing lets it through.

        Examples:
            - Reach a variable by name:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc["temperature"].band_count
              12

              ```
            - A name the store does not hold at all is a bare `KeyError`:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc["nope"]
              Traceback (most recent call last):
                  ...
              KeyError: 'nope'

              ```
            - A name the store *does* hold, but not as a data variable, is told apart: the
              message names the accessor that will read it, and that accessor does:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> try:
              ...     nc["lat_bnds"]
              ... except KeyError as miss:
              ...     print(miss.args[0].split(";")[0])
              'lat_bnds' is not a data variable, so it is not a key of `variables`
              >>> nc.get_variable("lat_bnds").shape
              (1, 128, 2)

              ```

        Read-only: `nc["t2m"] = other` raises `TypeError`, because a store is written
        through named methods rather than by item assignment.

        See Also:
            NetCDF.get_variable: The same lookup, raising `ValueError` instead.
            NetCDF.variables: The mapping this reads.
            NetCDF.add_variable: Writes a new variable; `set_variable` replaces one and
                `remove_variable` drops one.
        """
        return self.variables[name]

    def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray:
        """Refuse to become an array. Read one with :meth:`read_array` instead.

        :meth:`__iter__` and :meth:`__len__` make every `NetCDF` look like a sequence to
        NumPy, which would otherwise convert one by looping it. That answer is wrong in two
        different ways, and the second is dangerous:

        - a **container** yields its variable *names*, so `np.asarray(nc)` would build an
          array of strings — visibly not data;
        - a **variable** yields nothing at all, because a variable holds no data variables
          of its own. NumPy would see an empty sequence, and `np.mean(nc["ua"])` would
          answer `nan` for a cube of 557,056 real values rather than raise.

        The second is why this refuses rather than documents. `nan` is what a reader gets
        from averaging nothing, it looks like a legitimate result, and it can travel a long
        way before anyone questions it. Before the mapping protocol existed NumPy could not
        interpret a `NetCDF` at all and raised, which was the right answer; this restores it.

        The same judgement :meth:`Dataset.__bool__` already makes for `if nc:` — there is no
        honest single answer, so decline to invent one.

        `dtype=object` is the one request that is honoured: boxing a dataset in an object
        array reads nothing and invents nothing — the object is stored as itself — and it
        worked before the mapping protocol existed, so `np.array([nc], dtype=object)` still
        gives `(1,)` rather than starting to raise.

        Args:
            dtype: Requested dtype. `object` boxes the dataset; anything else is refused.
            copy: NumPy 2's copy-semantics flag. Accepted so the call signature matches;
                unused, since nothing is ever copied.

        Returns:
            numpy.ndarray: A 0-d object array holding this dataset, for `dtype=object`.

        Raises:
            TypeError: For any other dtype, including the default.

        Examples:
            - The refusal names the way to get the data:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> np.asarray(nc["temperature"])
              Traceback (most recent call last):
                  ...
              TypeError: a NetCDF cannot be converted to an array directly...

              ```
            - Reading the variable works, and is what the message points at:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc["temperature"].read_array().shape
              (4, 3, 5, 6)

              ```
            - Iterating and indexing are untouched — only the value coercion is refused:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> list(nc), len(nc)
              (['temperature'], 1)

              ```
            - Boxing in an object array still works, and holds the dataset itself:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> boxed = np.array([nc], dtype=object)
              >>> boxed.shape
              (1,)
              >>> boxed[0] is nc
              True

              ```

        See Also:
            NetCDF.read_array: Reads a variable's values as an array.
            NetCDF.coords: The coordinate axes, which are ordinary arrays.
            NetCDF.__iter__: What iterating a container yields, and why it is not data.
        """
        if dtype is not None and np.dtype(dtype) == object:
            # Boxing a dataset in an object array is not a coercion — nothing is read and
            # nothing is invented, the object is stored as itself. It worked before the
            # mapping protocol existed and there is no reason for this to take it away.
            box = np.empty((), dtype=object)
            box[()] = self
            return box
        raise TypeError(
            "a NetCDF cannot be converted to an array directly — a container holds many "
            "variables and a variable holds none of its own, so iterating either gives "
            "names or nothing rather than values. Read the data with "
            'nc["name"].read_array(), or take an axis from nc.coords["lat"].'
        )

    def __contains__(self, name: object) -> bool:
        """Whether `name` is one of this container's data variables.

        Args:
            name: The name to look for. A non-string is simply absent.

        Returns:
            bool: `True` when :attr:`variable_names` lists it.

        Examples:
            - Membership follows `variable_names`, not the store's whole array list — `lat`
              is a dimension coordinate, so it has values but is not a member, and those
              values come from :attr:`coords` rather than from an item lookup:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> "temperature" in nc, "lat" in nc, "nope" in nc
              (True, False, False)
              >>> nc.coords["lat"].tolist()
              [40.0, 41.0, 42.0, 43.0, 44.0]

              ```
            - Guard a lookup with it, which is what the protocol is for:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> [name for name in ("tas", "rsds", "pr") if name in nc]
              ['tas', 'pr']

              ```

        See Also:
            NetCDF.variable_names: What membership is decided against.
            NetCDF.coords: Where a dimension coordinate such as `lat` is read from —
                `get_variable` will **not** take it, and raises `ValueError` if asked.
            NetCDF.get_variable: Reads the *other* kind of non-member — a bounds or
                curvilinear-coordinate array such as `lat_bnds`, which the store declares
                and this enumeration still leaves out.
        """
        return name in self.variables

    def __iter__(self) -> Iterator[str]:
        """Iterate the **data-variable names**, in store order.

        Data variables only, matching :attr:`variables` and :attr:`variable_names` — a
        dimension coordinate such as `lat` is not yielded, so `list(nc)` and
        `nc.variable_names` agree. That choice is the one place this class could have
        collided with xarray twice over: `xarray.Dataset.__iter__` yields data variables
        too, but `xarray.Dataset.variables` includes coordinates while this class's does
        not. (Spelled out because `Dataset` is also the name of this class's own base,
        which has neither member.)

        **This made a container look like a sequence to everything that duck-types.**
        Before these dunders existed, a `NetCDF` had neither `__iter__` nor `__len__`, so
        NumPy treated it as an opaque object and `isinstance(nc, Iterable)` was `False`.
        Both changed:

        | expression | before | now |
        |---|---|---|
        | `isinstance(nc, Iterable / Sized / Container)` | `False` | `True` |
        | `np.asarray(nc)` | a 0-d object box | raises — see :meth:`__array__` |
        | `np.asarray(nc["t2m"])` | raises | raises |

        Left alone, NumPy would have converted a container by looping it — yielding an array
        of the *names* — and a variable into an empty array, since a variable yields nothing.
        :meth:`__array__` refuses both. A container's coercion therefore changes from a
        useless value to an error, and a variable's stays an error, which is the one that
        mattered: `np.mean` over that empty array answered `nan`. `isinstance(nc, Mapping)`
        and `isinstance(nc, Sequence)` are both still `False`.

        The change that remains is the `isinstance` row, and it has one practical
        consequence: a helper that accepts "anything iterable" used to reject a container
        outright and now accepts it, receiving a list of name strings. Gate on
        `isinstance(x, NetCDF)` first where that matters.

        `bool(nc)` is unaffected — :meth:`Dataset.__bool__` still refuses to collapse a
        raster to one truth value, and wins over `__len__`; use `len(nc) == 0` to ask
        whether a container is empty.

        Yields:
            str: Each data-variable name.

        Examples:
            - Iterating yields the names, in store order:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> list(nc)
              ['area', 'msk_rgn', 'pr', 'tas', 'ua']

              ```
            - A dimension coordinate is not among them, which is the whole choice:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> list(nc)
              ['temperature']
              >>> sorted(nc.dims)
              ['lat', 'lon', 'pressure_level', 'time']

              ```

        See Also:
            NetCDF.variable_names: The list this yields from.
            NetCDF.dims: The dimensions, which iteration deliberately skips.
        """
        return iter(self.variables)

    def __len__(self) -> int:
        """How many data variables the container holds.

        `bool(nc)` does **not** follow from this: :meth:`Dataset.__bool__` takes precedence
        over `__len__` and raises, because a raster's truth value is ambiguous. Ask
        `len(nc) == 0` for "does this container hold any variables". See :meth:`__iter__`
        for the rest of what adding these dunders changed about protocol dispatch.

        Returns:
            int: `len(variable_names)`. Reads no data.

        Examples:
            - Count what the store offers:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> len(nc)
              5

              ```
            - A store with one variable counts one, though it declares four dimensions:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> len(nc), len(nc.dims)
              (1, 4)

              ```

        See Also:
            NetCDF.variable_names: What is counted.
        """
        return len(self.variables)

    def keys(self) -> list[str]:
        """The data-variable names, in store order. Reads nothing.

        **Differs from xarray**, whose `keys()` is a live `KeysView` that tracks the
        dataset. This is a snapshot: a copy, taken when you call it, which is why it is safe
        to sort or reverse in place.

        Returns:
            list[str]: The same names as :attr:`variable_names`, as a fresh list.

        Examples:
            - A list, not a view, matching the mapping it delegates to:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> nc.keys()
              ['temperature']

              ```
            - A fresh copy each call, so reordering it in place disturbs nothing:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> names = nc.keys()
              >>> names.reverse()
              >>> names
              ['ua', 'tas', 'pr', 'msk_rgn', 'area']
              >>> list(nc)
              ['area', 'msk_rgn', 'pr', 'tas', 'ua']

              ```

        See Also:
            NetCDF.variable_names: The canonical list.
            NetCDF.items: The same names, paired with their variables.
        """
        return self.variables.keys()

    def values(self) -> list[NetCDF | LabeledArray]:
        """Every variable, loading each one.

        Loading is eager and all-or-nothing: the list is built by indexing every name, so
        one variable that will not open takes the whole call down. Iterate
        :attr:`variable_names` and index yourself when you need to survive that.

        Returns:
            list[NetCDF | LabeledArray]: A `NetCDF` per raster variable, a `LabeledArray`
            for the rest, in store order.

        Raises:
            RuntimeError: GDAL refuses one of the enumerated variables. Reachable on a
                **classic** container, whose subdataset list can name an array that will not
                open — see :meth:`__getitem__`, which raises it one variable at a time.

        Examples:
            - Each entry is the variable itself, so its properties are to hand:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> [variable.band_count for variable in nc.values()]
              [1, 1, 1, 1, 17]

              ```
            - Reduce over them, which is what having the objects is for:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> max(variable.rows for variable in nc.values())
              5

              ```

        See Also:
            NetCDF.items: The same variables, paired with their names.
            NetCDF.keys: The names alone, which reads nothing.
        """
        return self.variables.values()

    def items(self) -> list[tuple[str, NetCDF | LabeledArray]]:
        """`(name, variable)` for every variable, loading each.

        Eager in the same all-or-nothing way :meth:`values` is: every name is indexed before
        any pair is returned.

        Returns:
            list[tuple[str, NetCDF | LabeledArray]]: Pairs in store order.

        Raises:
            RuntimeError: GDAL refuses one of the enumerated variables — the `values`
                failure mode, since this loads the same objects.

        Examples:
            - Unpackable in a loop, as a mapping's items are:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> [(name, variable.band_count) for name, variable in nc.items()]
              [('area', 1), ('msk_rgn', 1), ('pr', 1), ('tas', 1), ('ua', 17)]

              ```
            - Filter on a property of the variable rather than on its name:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> [name for name, variable in nc.items() if variable.band_count > 1]
              ['ua']

              ```

        See Also:
            NetCDF.keys: The names alone, which reads nothing.
            NetCDF.values: The variables alone.
        """
        return self.variables.items()

    @property
    def variables(self) -> _LazyVariableDict:
        """All data variables as a lazy dict of `{name: subset}`.

        Variables are loaded on first access per key, not all at once.
        Cached after loading; invalidated by `add_variable` /
        `remove_variable` / `set_variable`.

        Each entry is whatever `get_variable` returns for that name: a `NetCDF`
        subset for a raster variable, a `LabeledArray` for one with no raster
        plane -- a 1-D array, or a non-numeric one of any rank. A store that
        holds both kinds yields both from one mapping, so check what you have
        before reaching for a raster method.

        Returns:
            _LazyVariableDict: Mapping from variable name to its subset. The concrete type
            is internal and not exported — treat it as a read-through
            `Mapping[str, NetCDF | LabeledArray]` whose `keys`, `values` and `items` return
            lists rather than views, and which refuses writes.

        Examples:
            - A store with a grid and a profile axis yields one of each kind:
                ```python
                >>> import numpy as np
                >>> from osgeo import gdal
                >>> from pyramids.netcdf import Container
                >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
                >>> group = store.GetRootGroup()
                >>> lat = group.CreateDimension("lat", None, None, 2)
                >>> lon = group.CreateDimension("lon", None, None, 3)
                >>> ilev = group.CreateDimension("ilev", None, None, 4)
                >>> grid = group.CreateMDArray(
                ...     "t2m", [lat, lon], gdal.ExtendedDataType.Create(gdal.GDT_Float32)
                ... )
                >>> grid.Write(np.arange(6, dtype="float32").reshape(2, 3))
                0
                >>> profile = group.CreateMDArray(
                ...     "hyai", [ilev], gdal.ExtendedDataType.Create(gdal.GDT_Float64)
                ... )
                >>> profile.Write(np.array([0.0, 0.25, 0.5, 1.0]))
                0
                >>> variables = Container(store).variables
                >>> sorted(variables.keys())
                ['hyai', 't2m']
                >>> variables["t2m"].shape
                (1, 2, 3)
                >>> variables["hyai"].values.tolist()
                [0.0, 0.25, 0.5, 1.0]

                ```

        See Also:
            `get_variable`: the accessor behind each entry, and the full account
                of when a name answers with a `LabeledArray`.
        """
        if self._cached_variables is None:
            self._cached_variables = _LazyVariableDict(self)
        return self._cached_variables

    @property
    def file_name(self):
        """File path, with the `NETCDF:"path":var` prefix stripped if present.

        Returns:
            str: Clean file path without the NETCDF prefix.
        """
        if self._file_name.startswith("NETCDF"):
            name = self._file_name.split(":")[1][1:-1]
        else:
            name = self._file_name
        return name

    @property
    def time_stamp(self):
        """Time coordinate values parsed from the CF-compliant `time` variable.

        Returns:
            list[str] | None: Formatted time strings, or None if no time
                dimension with a `units` attribute is found.
        """
        return self.get_time_variable()

    @property
    def _is_root_container(self) -> bool:
        """True for a whole multidimensional store, false for a variable of one.

        Three conditions that only mean something together: opened through the
        multidimensional API, not narrowed to a variable, and carrying no bands
        of its own. Six places asked it and each wrote the conjunction out, so
        "what counts as a container" was defined six times -- and the operations
        that refuse a container, the ones that fan out over it, and the one that
        reads from it all had to be kept in step by hand.

        Returns:
            bool: True when this object is the store rather than a variable in
                it.
        """
        return self._is_md_array and not self._is_subset and self.band_count == 0

    def _check_not_container(self, operation: str):
        """Raise ValueError if this is a root MDIM container (not a variable subset)."""
        if self._is_root_container:
            raise ValueError(
                f"Spatial operations are not supported on the NetCDF container. "
                f"Use nc.get_variable('var_name').{operation}(...) instead."
            )

    # ARC-35 interim guard: a `band_count == 0` container inherits the full single-raster facade
    # from `Dataset`, where each of these runs on a `(0, rows, cols)` store and produces an opaque
    # GDAL error or silently wrong output. Route the raster-only surface through
    # `_check_not_container` so a container fails with the "use get_variable(...)" message instead;
    # a `Variable` subset (band_count > 0) passes the guard and delegates unchanged. The structural
    # fix (make `Container` *compose* a spatial-template `Dataset`) is a larger follow-up.
    def stats(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.stats`."""
        self._check_not_container("stats")
        return super().stats(*args, **kwargs)

    def summary(
        self,
        variables: Sequence[str] | None = None,
        *,
        metrics: Sequence[str] = DEFAULT_METRICS,
        skipna: bool = True,
        ddof: int = 0,
    ) -> pd.DataFrame:
        """A per-variable summary table over the whole cube.

        Where `stats` reports one row per band of a single variable, `summary` reports one row
        per *variable*, each reduced over **all** of its values — every band / time step and every
        cell. It therefore works on a container, which `stats` refuses. Values are read in physical
        units (CF `scale_factor` / `add_offset` applied) with no-data left out, exactly as
        `read_array` returns them.

        Built on the shared :func:`pyramids.base._summary.variable_summary`, so this answers in the
        identical shape to :meth:`pyramids.netcdf.ugrid.UgridDataset.summary`.

        Args:
            variables: Which variables to summarise, in output-row order. `None` (the default)
                takes every variable in :attr:`data_vars`. An unknown name raises `KeyError`.
            metrics: Which statistics to report, in column order. Defaults to
                `("count", "min", "max", "mean", "std")`; `median` / `var` / `sum` / `prod` are
                available too.
            skipna: Leave no-data out of the statistics (the default) or let it propagate.
            ddof: Delta degrees of freedom for `std` / `var`. Defaults to 0.

        Returns:
            pandas.DataFrame: One row per variable (index name `"variable"`), one column per metric.
            `count` is `int64`; the rest are `float64`. A variable with no valid cell yields NaN
            statistics and a `count` of 0.

        Raises:
            KeyError: A requested variable is not in :attr:`data_vars`.
            TypeError: A requested variable is non-numeric.
            ValueError: An unknown metric.

        Examples:
            - Summarise every variable of a multi-variable file:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
              >>> df = nc.summary()
              >>> list(df.index)
              ['area', 'msk_rgn', 'pr', 'tas', 'ua']
              >>> list(df.columns)
              ['count', 'min', 'max', 'mean', 'std']

              ```
        """
        mapping = self.data_vars
        names = list(mapping) if variables is None else list(variables)
        arrays: dict[str, np.ndarray] = {}
        for name in names:
            if name not in mapping:
                raise KeyError(name)
            physical = _summary_physical(mapping[name])
            if physical is None:
                if variables is None:
                    warnings.warn(
                        f"skipping non-numeric variable {name!r} in summary",
                        stacklevel=2,
                    )
                    continue
                raise TypeError(f"variable {name!r} is non-numeric; cannot summarise")
            arrays[name] = physical
        return variable_summary(arrays, metrics=metrics, skipna=skipna, ddof=ddof)

    def slope(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.slope`."""
        self._check_not_container("slope")
        return super().slope(*args, **kwargs)

    def hillshade(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.hillshade`."""
        self._check_not_container("hillshade")
        return super().hillshade(*args, **kwargs)

    def write_array(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.write_array`."""
        self._check_not_container("write_array")
        return super().write_array(*args, **kwargs)

    def to_cog(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.to_cog`."""
        self._check_not_container("to_cog")
        return super().to_cog(*args, **kwargs)

    def to_geodataframe(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.to_geodataframe`."""
        self._check_not_container("to_geodataframe")
        return super().to_geodataframe(*args, **kwargs)

    def zonal_stats(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.zonal_stats`."""
        self._check_not_container("zonal_stats")
        return super().zonal_stats(*args, **kwargs)

    def sample(self, *args, **kwargs):  # type: ignore[override]
        """Container-guarded facade for `Dataset.sample`."""
        self._check_not_container("sample")
        return super().sample(*args, **kwargs)

    # NetCDF intentionally exposes a richer, variable/selector-oriented plot
    # signature than the band-oriented Dataset/RasterBase one; the override
    # is deliberate and not Liskov-substitutable.
    def animate(  # type: ignore[override]
        self,
        variable: str | None = None,
        dim: str | None = None,
        **kwargs: Any,
    ) -> Any:
        """Animate a variable along one of its non-spatial dimensions (issue #1380).

        The cube could already animate, but only through ``plot(animate=...)``, where the
        capability is easy to miss. This is the same path under the name the other classes use
        (:meth:`Dataset.animate`, :meth:`UgridDataset.animate`,
        :meth:`DatasetCollection.animate`), so one name finds it everywhere.

        The signature deliberately replaces the inherited raster one: a cube's frames come
        from one of its own dimensions, not from band indices, which is why :meth:`plot`
        overrides in the same way.

        Args:
            variable: Variable to animate. ``None`` (default) takes the only one.
            dim: Band dimension to animate along. ``None`` (default) takes the variable's
                primary band dimension.
            **kwargs: Everything :meth:`plot` accepts on its animate path — ``selectors``,
                ``colorbar``, ``color``, ``points``, ``basemap``, ``interval``, …

        Returns:
            cleopatra.glyphs.gridded.array_glyph.ArrayGlyph: The glyph, whose ``anim``
            attribute is the :class:`matplotlib.animation.FuncAnimation` — save with
            ``glyph.anim.save("out.gif")``. Note this differs from
            :meth:`UgridDataset.animate`, which returns the ``FuncAnimation`` itself: the two
            cleopatra glyph families return different objects and pyramids passes each through
            rather than wrapping one to look like the other. **Keep a reference to it** —
            matplotlib warns and stops an animation held only as a temporary.

        Raises:
            ValueError: No variable was given on a container, or ``variable`` is not one of
                its variables.
            KeyError: ``dim`` is not a band dimension of that variable.

        Note:
            The animate path is :meth:`plot`'s, so anything else that method rejects is
            rejected here the same way and with the same message.

        See Also:
            plot: One frame, or the same animation via ``animate=``.
            UgridDataset.animate: The unstructured counterpart.
        """
        return self.plot(
            variable=variable, animate=dim if dim is not None else True, **kwargs
        )

    def plot(  # type: ignore[override]
        self,
        variable: str | None = None,
        *,
        selectors: Selectors | None = None,
        facet: FacetSpec | None = None,
        axes: CoordinateSpec | None = None,
        kind: str = "auto",
        animate: bool | str | None = None,
        chunks: Any | None = None,
        basemap: bool | str | dict[str, Any] | Basemap | None = None,
        exclude_value: Any | None = None,
        title: str | None = None,
        colorbar: bool | ColorBar | None = None,
        points: np.ndarray | PointOverlay | None = None,
        color: ColorScaling | None = None,
        contour: Contour | None = None,
        cells: CellValues | None = None,
        data_style: DataStyle | None = None,
        fig: Figure | None = None,
        ax: Axes | None = None,
        **kwargs: Unpack[PlotKwargs],
    ):
        """Plot a 2-D slice of a NetCDF variable using the plot vocabulary.

        The public surface is shaped around **variables** and **dimensions** — ``band``
        is not a NetCDF concept and has been removed from the signature. Variable
        selection is by name; the slice to render is pinned via a :class:`Selectors`
        option bag (``time`` / ``level`` / ``member`` / ``sel`` / ``isel`` / ``method``); multi-panel
        layout is described by a :class:`FacetSpec` bag (``col`` / ``row`` / ``col_wrap``);
        and the spatial-axis interpretation by a :class:`CoordinateSpec` bag (``coords`` /
        ``x_dim`` / ``y_dim``). Each is a frozen dataclass — construct it inline at the call
        site. Colour and the render options mirror :meth:`Dataset.plot`: pass ``cmap`` /
        ``vmin`` / ``vmax`` / ``robust`` / ``extend`` as loose keywords and the grouped
        ``color`` / ``colorbar`` / ``contour`` / ``cells`` / ``data_style`` bags (all
        cleopatra's), so no NetCDF-specific colour bag is needed. ``colorbar=False`` drops
        the colour bar.

        On a **root MDIM container** the ``variable=`` argument is required:

        ```python
        from pyramids.netcdf import NetCDF, Selectors
        nc.plot(variable="t2m", selectors=Selectors(time="2024-01-15"))
        ```

        On a **variable subset** (the result of :meth:`get_variable`) ``variable=``
        may be omitted or must equal the pinned variable name; otherwise the call
        is rejected, mirroring the :meth:`read_array` contract.

        Args:
            variable (str, optional):
                Name of the variable to plot. Required on the root MDIM container;
                must be ``None`` or equal to the pinned variable name on a subset.
                Defaults to None.
            selectors (Selectors, optional):
                Dim-selector bag. See :class:`Selectors` for the field list. A
                missing bag is treated as :class:`Selectors`\\ () (all fields
                ``None``). Defaults to None.
            facet (FacetSpec, optional):
                Faceting bag. See :class:`FacetSpec` for the field list. A missing
                bag (or one where both ``col`` and ``row`` are ``None``) routes
                the call to the single-panel static-plot path. Defaults to None.
            axes (CoordinateSpec, optional):
                Spatial-axis interpretation bag (:class:`CoordinateSpec`): an explicit
                curvilinear ``(x, y)`` coord pair (``coords=``), or the ``x_dim`` / ``y_dim``
                dimension names when they cannot be auto-resolved. ``coords`` accepts a
                length-2 sequence of variable-name strings (looked up on the parent
                container) or of numpy arrays (1-D matching the data x/y axis, or 2-D
                matching ``(rows, cols)``); when omitted, curvilinear coords are auto-detected
                from the CF ``coordinates`` attribute, then the well-known conventions (WRF
                ``XLAT``/``XLONG``, ROMS ``lat_rho``/``lon_rho``, NEMO ``nav_lat``/``nav_lon``),
                else the renderer falls back to ``extent=self.bbox`` (imshow). ``x_dim`` /
                ``y_dim`` set the lon/lat dimensions for variables whose axes aren't trailing
                and carry no CF metadata (e.g. CAM ``T(time, lat, lev, lon)``). Defaults to
                None.
            kind (str, optional):
                Render kind forwarded to cleopatra's ``ArrayGlyph.plot``.
                One of ``"auto"``, ``"imshow"``, ``"pcolormesh"``,
                ``"contour"``, ``"contourf"``. ``"auto"`` routes to
                ``pcolormesh`` when curvilinear ``coords`` are present,
                else ``imshow``. Defaults to ``"auto"``.
            animate (bool or str, optional):
                When set, render the variable as an animation across a
                band dim instead of a single 2-D slice. ``True`` animates
                along the variable's primary band dim (typically
                ``time``) — only valid when exactly one band dim remains
                after the selectors collapse the others. A string names
                the dim to animate along. ``None`` (default) returns a
                static plot. Mutually exclusive with faceting and with
                any selector that pins the animated dim. Defaults to
                None. On this animate path a
                ``frame_label=pyramids.plot.FrameLabel(...)`` (cleopatra
                >= 0.28) kwarg styles the per-frame caption — colour,
                size and placement — and is forwarded to cleopatra's
                animate call.
            chunks (Any, optional):
                Chunking spec forwarded to :meth:`read_array` for the
                static-plot path. ``None`` (default) preserves the eager
                read. Any of ``int`` / ``tuple`` / ``dict`` / ``"auto"``
                switches to the dask-backed lazy read and only the
                rendered slice is materialised. Has no effect on the
                ``animate=`` path. Defaults to None.
            basemap (bool, str, or Basemap, optional):
                Reference layer, dispatched by type. ``True`` or a named basemap
                tile provider overlays a pyramids web-tile basemap; a
                ``pyramids.plot.Basemap`` draws a relief/features
                layer instead (not supported on the faceted path). Defaults to None.
            exclude_value (Any, optional):
                Pixel value to mask out before plotting. Defaults to None.
            title (str, optional):
                Plot title. Defaults to None.
            colorbar (bool or ColorBar, optional):
                Colour-bar spec. A ``pyramids.plot.ColorBar(label=…, …)``
                draws a configured bar; ``False`` hides it and
                ``None`` uses cleopatra's default. Only forwarded when set. Default
                is ``None``.
            points (np.ndarray or PointOverlay, optional):
                Point overlay for the static / animate paths. A 3-column array
                ``(value, row, col)`` draws unstyled points; pass a
                ``pyramids.plot.PointOverlay(...)`` to style them. Only forwarded
                when set — the multi-panel ``facet`` path has no ``points``
                parameter and raises if one is supplied. Default is ``None``.
            color (ColorScaling, optional):
                Colour-scale spec ``pyramids.plot.ColorScaling`` (linear / power / sym-log /
                boundary / midpoint norm), e.g. ``ColorScaling.power(gamma=0.7)``. Default
                ``None``.
            contour (Contour, optional):
                Contour-line spec ``pyramids.plot.Contour(levels=…, labels=…, label_kw=…)``.
                Default ``None``.
            cells (CellValues, optional):
                Per-cell value annotation ``pyramids.plot.CellValues(show=…, size=…,
                background_threshold=…)``. Default ``None``.
            data_style (DataStyle, optional):
                Data-style / relief spec ``pyramids.plot.DataStyle(style=…, hillshade=…)``.
                Default ``None``.
            fig (matplotlib.figure.Figure, optional):
                Draw into this figure instead of creating one. Pass it alongside ``ax``;
                supplying ``fig`` on its own currently raises inside cleopatra
                (serapeum-org/cleopatra#326). Default is ``None``.
            ax (matplotlib.axes.Axes, optional):
                Draw into these axes instead of creating them, so several variables can
                share one figure — e.g. a ``plt.subplots`` grid — while each panel keeps
                its georeferenced extent. An axes already carries its figure, so ``ax`` on
                its own is sufficient. Default is ``None``.
            **kwargs (Unpack[PlotKwargs]):
                cleopatra's typed plot keywords, forwarded verbatim to
                :meth:`Analysis.plot <pyramids.dataset.engines.Analysis.plot>` — the
                xarray-aligned colour options (``cmap`` / ``vmin`` / ``vmax`` / ``robust`` /
                ``extend``), appearance (``ax`` / ``figsize``), and colorbar options. This
                mirrors :meth:`Dataset.plot`'s ``**kwargs``.

        Returns:
            ArrayGlyph: A cleopatra ``ArrayGlyph`` wrapping the rendered figure. Use the glyph's
                matplotlib handles (``glyph.ax`` / ``glyph.fig`` / ``glyph.im``) to decorate the
                plot further. In particular, to overlay a **coastline** (or borders / land / ocean /
                rivers / lakes) on top of the data, call cleopatra's reference helper on the axes —
                passing the CRS the data was plotted in (its own CRS — no reprojection needed) so the
                layer lines up::

                    from cleopatra.basemap.reference import add_features
                    var = nc.get_variable("t2m")
                    glyph = var.plot()
                    add_features(glyph.ax, "coastline", crs=var.epsg, zorder=5)

                ``add_features`` fetches Natural Earth data (cached under ``~/.cleopatra``), so it
                needs the ``[viz]`` extra and network access on first use. A relief backdrop is
                available the same way via :func:`cleopatra.basemap.reference.add_relief`.

        Raises:
            TypeError: If any of the Sentinel-only kwargs (``rgb``,
                ``surface_reflectance``, ``cutoff``, ``percentile``,
                ``overview``, ``overview_index``) is passed. Each
                rejection message names the aligned replacement.
            ValueError: If called on a root MDIM container without
                ``variable=``, if ``variable=`` is passed on a subset and
                does not match the pinned variable name, if the resolved
                selectors do not pin to a single 2-D slice, or if
                ``axes.coords`` is malformed.

        Examples:
            - Plot the first time step of a variable on a container. Tagged
              ``+SKIP`` because rendering requires the optional ``[viz]``
              extra (cleopatra + matplotlib):

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import NetCDF, Selectors, GeoReference
              >>> arr = np.random.rand(4, 8, 8).astype(np.float32)
              >>> nc = NetCDF.from_array(
              ...     arr,
              ...     geo_ref=GeoReference(top_left_corner=(0, 0), cell_size=0.1, epsg=4326),
              ...     variable_name="t2m",
              ... )
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", selectors=Selectors(isel={"time": 0}),
              ... )

              ```

            - Pick a time slice by label — the ``Selectors.time`` alias
              is equivalent to ``Selectors(sel={"time": value})``:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", selectors=Selectors(time=2),
              ... )

              ```

            - Pin both time and level on a 4-D ``(time, pressure_level,
              lat, lon)`` variable. The selectors collapse both band
              dims to a single 2-D slice — equivalent to
              ``var.sel(time=12).sel(pressure_level=500)``:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="temperature",
              ...     selectors=Selectors(time=12, level=500),
              ... )

              ```

            - Use an explicit ``sel`` dict instead of the convenience
              aliases — keys must match the variable's band-dim names:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", selectors=Selectors(sel={"time": 2}),
              ... )

              ```

            - Use an ``isel`` dict to address slices positionally. Each
              integer is mapped to the corresponding coord value via
              ``_band_dim_values_map``:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", selectors=Selectors(isel={"time": 0}),
              ... )

              ```

            - All six Sentinel-only kwargs are rejected with a hint at
              the aligned replacement. These doctests run because
              the gate fires before any cleopatra import:

              ```python
              >>> nc.plot(variable="t2m", rgb=[0, 1, 2])  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              TypeError: ...rgb=...

              ```

              ```python
              >>> nc.plot(variable="t2m", surface_reflectance=10000)  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              TypeError: ...surface_reflectance...

              ```

              ```python
              >>> nc.plot(variable="t2m", cutoff=[0.1, 0.9])  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              TypeError: ...cutoff...

              ```

              ```python
              >>> nc.plot(variable="t2m", percentile=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              TypeError: ...robust=True...

              ```

              ```python
              >>> nc.plot(variable="t2m", overview=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              TypeError: ...overview=...

              ```

              ```python
              >>> nc.plot(variable="t2m", overview_index=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              TypeError: ...overview_index=...

              ```

            - The legacy ``band=`` kwarg still works as an escape hatch
              but emits a :class:`DeprecationWarning`. Prefer
              ``Selectors(time=...)`` for new code:

              ```python
              >>> import warnings
              >>> with warnings.catch_warnings(record=True) as caught:  # doctest: +SKIP
              ...     warnings.simplefilter("always")
              ...     cleo = nc.plot(variable="t2m", band=2)
              >>> caught[0].category.__name__  # doctest: +SKIP
              'DeprecationWarning'

              ```

            - Render a WRF-style curvilinear NetCDF on its real lat/lon
              grid. With 2-D ``XLAT`` / ``XLONG`` coord variables on the
              container, pyramids auto-detects them and routes the
              renderer to ``pcolormesh``:

              ```python
              >>> cleo = nc.plot(variable="CANWAT", kind="pcolormesh")  # doctest: +SKIP

              ```

            - Pass an explicit curvilinear coord pair by variable name —
              useful when the variable has no CF ``coordinates``
              attribute and the convention does not match
              WRF / ROMS / NEMO:

              ```python
              >>> from pyramids.netcdf import CoordinateSpec
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="CANWAT",
              ...     axes=CoordinateSpec(coords=("XLONG", "XLAT")),
              ... )

              ```

            - Pick a non-default render kind. ``"contourf"`` produces
              filled contours from the same data; ``"auto"`` (the
              default) picks ``pcolormesh`` when curvilinear coords are
              present, else falls back to ``imshow``. Discrete contour
              levels live on a :class:`~cleopatra.styling.params.Contour` bag:

              ```python
              >>> from cleopatra.styling.params import Contour
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m",
              ...     kind="contourf",
              ...     contour=Contour(levels=10),
              ... )

              ```

            - Render with explicit 2-D coord arrays via
              ``axes=CoordinateSpec(coords=(x, y))``. The arrays bypass the CF /
              convention auto-detection step and route the renderer to
              ``pcolormesh``:

              ```python
              >>> import numpy as np
              >>> x2d, y2d = np.meshgrid(
              ...     np.linspace(0, 10, 4), np.linspace(0, 10, 4),
              ... )
              >>> arr = np.random.rand(3, 4, 4).astype(np.float32)
              >>> nc_curv = NetCDF.from_array(
              ...     arr,
              ...     geo_ref=GeoReference(top_left_corner=(0, 0), cell_size=1.0, epsg=4326),
              ...     variable_name="t2m",
              ... )
              >>> from pyramids.netcdf import CoordinateSpec
              >>> cleo = nc_curv.plot(  # doctest: +SKIP
              ...     variable="t2m", axes=CoordinateSpec(coords=(x2d, y2d)),
              ... )

              ```

            - Robust (percentile-based) colour limits — clip to the 2nd / 98th
              percentile of the rendered slice. Colour controls are loose
              keywords, mirroring :meth:`Dataset.plot`:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m",
              ...     cmap="viridis",
              ...     robust=True,
              ... )

              ```

            - Disable the colorbar with ``colorbar=False`` (cleopatra renders
              no bar), mirroring :meth:`Dataset.plot`:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", colorbar=False,
              ... )

              ```

            - Facet over the time dim. :class:`FacetSpec` lists the
              column dim (and optionally a row dim and a wrap value).
              The return type becomes
              :class:`cleopatra.glyphs.gridded.array_glyph.FacetGrid`:

              ```python
              >>> from pyramids.netcdf import FacetSpec
              >>> grid = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", facet=FacetSpec(col="time"),
              ... )

              ```

            - Facet a 4-D variable across both axes with ``col`` and
              ``row``. ``col_wrap`` is ignored when ``row`` is given:

              ```python
              >>> grid = nc.plot(  # doctest: +SKIP
              ...     variable="temperature",
              ...     facet=FacetSpec(col="time", row="pressure_level"),
              ... )

              ```

            - Wrap a single-axis facet into a grid via ``col_wrap``.
              ``N=4`` panels with ``col_wrap=3`` wrap to a ``2x3``
              layout with one hidden slot:

              ```python
              >>> grid = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", facet=FacetSpec(col="time", col_wrap=3),
              ... )

              ```

            - Faceting on a dim that is also pinned by a selector
              raises :class:`ValueError` before any I/O:

              ```python
              >>> nc.plot(  # doctest: +IGNORE_EXCEPTION_DETAIL
              ...     variable="t2m",
              ...     selectors=Selectors(time=0),
              ...     facet=FacetSpec(col="time"),
              ... )
              Traceback (most recent call last):
                  ...
              ValueError: Cannot facet on 'time'...

              ```

            - Animate along the primary band dim with ``animate=True``.
              The facade resolves the single free band dim (``time``
              here) and streams frames lazily via a per-frame
              ``data_getter`` so the animation never builds a 3-D stack:

              ```python
              >>> cleo = nc.plot(variable="t2m", animate=True)  # doctest: +SKIP

              ```

            - Name the animation dim explicitly. The string must match
              one of the variable's band-dim names. ``animate="time"``
              is equivalent to ``animate=True`` when ``time`` is the
              only free band dim; the explicit form is required on
              variables with more than one free band dim:

              ```python
              >>> cleo = nc.plot(variable="t2m", animate="time")  # doctest: +SKIP

              ```

            - An unknown ``animate=`` dim name is rejected before any
              I/O. The error message lists the available band dims so
              typos are easy to spot:

              ```python
              >>> nc.plot(variable="t2m", animate="bogus")  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                  ...
              KeyError: "`animate='bogus'` is not a band dim..."

              ```

            - Pinning a dim and then asking to animate over it
              raises :class:`ValueError`:

              ```python
              >>> nc.plot(  # doctest: +IGNORE_EXCEPTION_DETAIL
              ...     variable="t2m",
              ...     selectors=Selectors(time=0),
              ...     animate="time",
              ... )
              Traceback (most recent call last):
                  ...
              ValueError: Cannot animate on 'time'...

              ```

            - Switch the static-plot path to a lazy dask read with
              ``chunks=``. Only the rendered slice is materialised —
              useful when the variable is very large and a full eager
              read would waste memory:

              ```python
              >>> cleo = nc.plot(  # doctest: +SKIP
              ...     variable="t2m", chunks={"rows": 5, "cols": 5},
              ... )

              ```
        """
        # ``colorbar``/``points`` and the typed render groups (``color`` / ``contour`` /
        # ``cells`` / ``data_style``) are hoisted to explicit params for parity with
        # ``Dataset.plot``. Spread the explicitly-set ones as their own ``**`` — kept in a
        # separate dict, not merged into the typed ``**kwargs`` (whose ``PlotKwargs``
        # TypedDict has no group keys) — so the multi-mode render path consumes them.
        # Only inject when set: the facet path rejects ``points`` outright, so a default
        # ``None`` must not reach it, and an unset group must not override cleopatra's
        # backend default for that group.
        group_kwargs = nonnull_group_kwargs(
            color=color, contour=contour, cells=cells, data_style=data_style
        )
        if colorbar is not None:
            group_kwargs["colorbar"] = colorbar
        if points is not None:
            group_kwargs["points"] = points
        return NetCDFPlot(self).run(
            variable,
            selectors=selectors,
            facet=facet,
            axes=axes,
            kind=kind,
            animate=animate,
            chunks=chunks,
            basemap=basemap,
            exclude_value=exclude_value,
            title=title,
            fig=fig,
            ax=ax,
            **group_kwargs,
            **kwargs,
        )

    # NetCDF adds a leading `variable` selector (and unpack/bbox/masked
    # options) on top of the RasterBase read_array contract; the wider
    # signature is deliberate and not Liskov-substitutable.
    def read_array(  # type: ignore[override]
        self,
        variable: str | None = None,
        band: int | None = None,
        window: list[int] | None = None,
        unpack: bool = True,
        *,
        bbox: tuple[float, float, float, float] | list[float] | None = None,
        epsg: Any = None,
        chunks: Any = None,
        lock: Any = None,
        masked: bool = False,
        bbox_rounding: str = "cover",
        squeeze: bool = False,
    ) -> ArrayLike:
        """Read array from the dataset (eager by default, lazy with `chunks`).

        A CF-packed variable (`scale_factor` / `add_offset`) is read in physical units
        unless `unpack=False` asks for the stored counts. Its no-data / `_FillValue`
        sentinel stays a **stored** value, so either compare it against an `unpack=False`
        read or let `masked=True` build the mask.

        Args:
            variable: When this instance is a root MDIM container,
                the variable name to read. When the instance is
                already a variable subset (`nc.get_variable("x")`)
                this argument must be `None` — the variable is
                already pinned.
            band: Band index to read, or None for all bands. Only
                honored on the eager path (`chunks=None`).
            window: Spatial window to read. Only honored on the
                eager path. Mutually exclusive with `bbox`.
            unpack: Apply the variable's CF packing,
                `real = raw * scale + offset`, from its `scale_factor`
                and/or `add_offset`. **Defaults to True** (#1124); pass
                `False` for the stored counts. The pair is the one
                `_effective_packing` resolves: the variable's own
                `scale_factor` / `add_offset` when it declares them, else
                the classic band's (a container opened with
                `open_as_multi_dimensional=False`). The eager and lazy
                paths apply the same pair exactly once, and a packed
                result is `float64`. A variable that declares neither is
                returned unchanged, same dtype.
                Applied lazily via :mod:`dask.array` arithmetic when
                `chunks` is given — the compute graph stays lazy
                until the caller materializes it.
            bbox (keyword-only): `(west, south, east, north)` quadruple
                in the CRS named by `epsg`. Internally wrapped in a
                one-row :class:`pyramids.feature.FeatureCollection` via
                :meth:`pyramids.feature.FeatureCollection.from_bbox`
                and routed through the same window path. Honored on
                the **eager path only** — same constraint as `window`.
                Mutually exclusive with `window`; combining with
                `chunks` raises :class:`ValueError` (mirroring
                :class:`pyramids.dataset.engines.IO.read_array`'s
                `chunks=` + `window=` rule).
            epsg (keyword-only): CRS for `bbox` — anything geopandas
                accepts for `crs=` (EPSG int, `"EPSG:4326"`, WKT,
                :class:`pyproj.CRS`). Defaults to the dataset's own
                CRS, so a bbox in the dataset's native CRS needs no
                extra argument.
            chunks: Chunking spec for a lazy return. `None` (the
                default) returns an eager :class:`numpy.ndarray` and
                preserves the legacy behavior. Any of `int`,
                `tuple`, `dict`, or the string `"auto"` switches
                to a :class:`dask.array.Array` backed by MDArray
                chunk reads. Defaults chunked at the variable's
                native `GetBlockSize` (see
                :attr:`pyramids.netcdf.models.VariableInfo.block_size`);
                a conservative `(1,..., rows, cols)` fallback is
                used when the driver doesn't advertise one.
            lock: Lock passed to the underlying
                :class:`pyramids.base._file_manager.CachingFileManager`.
                `None` → :func:`pyramids.base._locks.default_lock`
                (a :class:`SerializableLock`, or a
                `dask.distributed.Lock` when a client is active).
                `False` → :class:`pyramids.base._locks.DummyLock`.
                Only meaningful when `chunks` is not `None`.
            masked: When `True`, return a :class:`numpy.ma.MaskedArray`
                with the variable's no-data / fill cells masked. On the
                eager path the mask is built from the raw stored values
                before any `unpack` scaling; on the lazy path (`chunks=`)
                the same cells are masked after unpack, against the
                physical sentinel. The lazy result (a lazy `dask` masked
                array until computed) carries the **identical mask** and the
                **identical unmasked physical values** as the eager read, and
                `filled(...)` agrees cell for cell. Only the raw data stored
                *under* a masked cell may differ between the two — the eager
                path keeps the stored sentinel there, the lazy path the
                unpacked one — which is immaterial to a masked array (every
                reduction, comparison and `filled()` ignores masked
                positions). Default is `False`.
            bbox_rounding (keyword-only): How a `bbox` (or geometry
                `window`) is snapped to whole pixels — `"cover"`
                (default; floor/ceil so every overlapping pixel is kept)
                or `"nearest"` (round each edge to the closest pixel
                boundary, the tightest window). Forwarded verbatim to
                `Dataset.read_array`; ignored on the lazy path and for
                pixel windows. Any other value raises `ValueError`.
            squeeze (keyword-only): How the non-spatial (band) dimensions
                shape the result, making the eager and lazy paths
                interchangeable (#1226, #1241). `False` (default) returns
                the **dimension-preserving** `(*band_sizes, rows, cols)` on
                **both** paths (the faithful, xarray-like layout) — a size-1
                band axis is kept, not squeezed. `True` returns the classic
                raster `(bands, rows, cols)` flatten (every band dimension
                collapsed into one row-major axis, a singleton squeezed to
                2-D) on **both** paths. Either way the eager and lazy reads
                return the identical shape. The default flipped to
                dimension-preserving in #1241 (previously the eager path
                flattened + squeezed); pass `squeeze=True` for the legacy
                classic-raster layout. On a `band=` read (a single plane)
                it has no effect.

        Returns:
            np.ndarray or dask.array.Array: The array data, eager
            (numpy) by default or lazy (dask) when `chunks` is
            supplied, in physical `float64` units when `unpack=True`
            and the variable is packed. The lazy array computes
            chunk-by-chunk through
            `md_arr.ReadAsArray(array_start_idx=starts, count=counts)`.

        Raises:
            ValueError: If called on a root MDIM container without a
                `variable` argument, when a subset is called with a
                conflicting `variable` name, when both `window` and
                `bbox` are supplied, or when both `chunks` and
                `bbox` are supplied (the lazy path doesn't yet
                honour bbox windowing — matching
                :class:`pyramids.dataset.engines.IO.read_array`'s
                `chunks=` + `window=` rule).
            ImportError: If `chunks` is given but `dask` is not
                installed. Install the `[lazy]` extra.

        Note:
            Two limitations are specific to the lazy (`chunks`) path:

            * **Open-handle lifetime.** A lazy read parks a live GDAL handle in the process-global
              `pyramids.base._file_manager.FILE_CACHE` (via `CachingFileManager`) so later chunk reads
              can reuse it. It is released two ways (#727): when the lazy read's result — **and every
              array derived from it** (`unpack=True`, an `open_mfdataset` stack, a `plot(chunks=)`, a
              slice or dask-arithmetic result) — is dropped or garbage-collected (a `weakref.finalize`
              on the manager, kept alive by the chunk readers in the dask graph, evicts and closes it);
              **and** when this `NetCDF`'s `close()` is called, which evicts the handles its lazy reads
              parked even while a lazy array from it is still alive. It is also released under LRU
              pressure or at interpreter exit. A lazy array stays usable after `close()` — its manager
              re-opens the handle on the next chunk read.

              Opening the **same file again in the same process while a handle is still parked** would
              leave two live GDAL handles to one NetCDF, which can crash GDAL on Windows. `read_file`
              now guards against this (#1224): it releases any parked handle for the path before
              reopening and warns, and a lazy array still referencing the file re-opens transparently
              on its next chunk read. To avoid the warning (and re-parking a second handle during
              continued concurrent use), **drop the lazy array(s)** or **`close()` the `NetCDF`**
              before reopening.
            * **Axis plane and shape.** The lazy path resolves the raster plane the same way the
              eager path does — explicit `x_dim` / `y_dim` (carried on the `get_variable` subset) or
              CF detection, falling back to the trailing two dimensions — and moves it to the
              trailing two axes, north-up / west-first, so a lazy read is oriented on the *same*
              plane as the eager read (#728). It reads the **entire source variable** in storage
              order and does **not** apply a prior `sel` / `crop` / `window` carried on the subset
              (those materialize the eager raster only): slice the returned dask array yourself, or
              read a selected / cropped subset eagerly. For a subset with no active selection the
              eager and lazy reads return the identical dimension-preserving shape by default
              (`squeeze=False`, #1241): both keep one leading axis per non-spatial dimension in
              storage order. Pass `squeeze=True` for the classic flattened `(bands, rows, cols)`
              layout (a singleton squeezed to 2-D) on both paths. Chunking is computed in *storage* order
              (before the plane is moved to the trailing axes), so for a non-trailing plane the
              default `chunks="auto"` may split the resolved plane finely and the named `chunks=`
              aliases (`rows` / `cols`) map to storage axes, not the resolved plane — pass explicit
              integer `chunks=` per axis for optimal chunking on such a variable.

        Examples:
            - Eager bbox read on a root container — the container
              auto-routes to the named variable. The noah fixture's
              geotransform is `cell_size=0.5°`, `origin=(0, 90)`,
              512×512 cells — so its coordinate range is
              `x ∈ [0, 256)` and `y ∈ (-166, 90]`. The bbox
              below sits well inside that range:
                ```python
                >>> from pyramids.netcdf import NetCDF
                >>> nc = NetCDF.read_file(
                ...     "tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc"
                ... )
                >>> arr = nc.read_array(
                ...     variable="Band1",
                ...     bbox=(10.0, -50.0, 50.0, -20.0),
                ... )
                >>> arr.ndim in (2, 3)
                True

                ```
            - A packed variable reads in physical units by default. The
              fixture's `z` is stored in `[-100, 100]` with
              `scale_factor=0.01` and `add_offset=1.5`:
                ```python
                >>> from pyramids.netcdf import NetCDF
                >>> nc = NetCDF.read_file(
                ...     "tests/data/netcdf/coards__4v__1d2-2d2__scaleoffset__y-asc.nc",
                ...     open_as_multi_dimensional=True,
                ... )
                >>> z = nc.get_variable("z")
                >>> float(z.read_array(band=0, unpack=False).max())
                100.0
                >>> physical = z.read_array(band=0)
                >>> float(physical.max()), physical.dtype
                (2.5, dtype('float64'))
                >>> float(nc.read_array("z", band=0).min())
                0.5

                ```

        See Also:
            - :meth:`pyramids.dataset.Dataset.read_array`: the same
              `bbox=` / `epsg=` surface for plain rasters.
            - :meth:`crop`: clip the whole dataset by bbox.
        """
        read_window = self._resolve_bbox_to_window(window, bbox, epsg, chunks)
        is_container = self._is_root_container
        if is_container:
            if variable is None:
                self._check_not_container("read_array")
            subset = self.get_variable(cast("str", variable))
            if not isinstance(subset, NetCDF):
                # `get_variable` hands back a `LabeledArray` for anything
                # GDAL cannot expose as a raster -- a 1-D array (`time_bounds`,
                # `band_id`, a grouped store's `flight_03/CO`) or a non-numeric
                # one. `variable_names` enumerates several such arrays on the
                # GOES fixture, so this is a name the container advertises;
                # delegating blindly answered it with
                # `AttributeError: 'MDArray' object has no attribute
                # 'read_array'`, which reads as a pyramids bug rather than a
                # shape mismatch.
                return self._read_non_raster_variable(
                    cast("str", variable),
                    band,
                    read_window,
                    chunks,
                    masked,
                    unpack,
                    subset,
                )
            return subset.read_array(
                band=band,
                window=read_window,
                unpack=unpack,
                chunks=chunks,
                lock=lock,
                masked=masked,
                bbox_rounding=bbox_rounding,
                squeeze=squeeze,
            )
        if variable is not None and variable != self._source_var_name:
            raise ValueError(
                f"This NetCDF instance is already pinned to variable "
                f"{self._source_var_name!r}; cannot re-read as "
                f"{variable!r}. Call read_array on the parent container "
                "instead."
            )
        if chunks is None:
            result = self._read_array_eager(
                band, read_window, masked, bbox_rounding, unpack
            )
            # squeeze=False (the default, #1241) converges the eager shape on the lazy one: undo
            # GDAL's row-major band flatten and the singleton squeeze so a full read returns the
            # dimension-preserving (*band_sizes, rows, cols) (#1226). Only for a full read; a
            # band= read already selects a single plane.
            if not squeeze and band is None:
                result = self._preserve_band_dims(result)
        else:
            result = self._read_array_lazy_processed(
                chunks, lock, unpack, masked, squeeze, read_window
            )
        return cast(ArrayLike, result)

    def _read_array_lazy_processed(
        self,
        chunks: Any,
        lock: Any,
        unpack: bool,
        masked: bool,
        squeeze: bool,
        window: Any = None,
    ) -> Any:
        """The lazy dask read with unpack, masking and `squeeze` applied, in that order.

        Split out of :meth:`read_array` so its eager/lazy dispatch stays within the cognitive
        complexity budget. Mirrors the eager arm: the same `_effective_packing` pair, the same
        physical-sentinel mask, and the same classic-flatten `squeeze` convergence (#1226).

        Args:
            chunks: The dask chunk spec forwarded to :meth:`_read_array_lazy`.
            lock: The read lock forwarded to :meth:`_read_array_lazy`.
            unpack: Apply the variable's CF packing to the lazy array.
            masked: Mask the no-data cells after unpack, against the physical sentinel.
            squeeze: Flatten the separate band axes into one classic `(bands, rows, cols)` axis.

        Returns:
            Any: The lazy `dask` array (a masked dask array when `masked`), still uncomputed.
        """
        result = self._read_array_lazy(chunks, lock)
        if window is not None:
            # Slice the oriented `(*band_sizes, y, x)` array to the pixel window so a chunked read
            # materialises only the requested block, not the whole variable (#1225). The window is
            # x-first `[xoff, yoff, xsize, ysize]`; the trailing axes are (y, x), north-up like the
            # eager windowed read, so the slice lines up with `read_array(window=...)` eagerly.
            xoff, yoff, xsize, ysize = (int(v) for v in window)
            result = result[..., yoff : yoff + ysize, xoff : xoff + xsize]
        # The lazy path builds its array straight from the MDArray rather than
        # through the raster read, so it applies the packing itself -- from
        # `_effective_packing`, the same resolver the eager arm uses. Reading
        # only `_scale` / `_offset` here made the two answer in different units
        # on a classic-opened file, where the variable carries no packing of its
        # own but the driver's band does: eager 2.5, lazy 100.0.
        if unpack:
            result = apply_unpack(result, *self._effective_packing())
        # Mask after unpack, against the physical sentinel (`_read_no_data`), so the lazy
        # masked read marks exactly the eager fill cells (#1227): the mask and the unmasked
        # physical values match the eager read. Only the raw value left under a masked cell
        # differs (unpacked here vs the stored sentinel eager), which a masked array ignores.
        if masked:
            result = mask_no_data(result, _read_no_data(self))
        # squeeze=True converges the lazy shape on the eager one: flatten the separate band
        # axes into a single row-major band axis and squeeze a singleton to 2-D, matching the
        # classic-raster eager read (#1226).
        if squeeze:
            result = self._flatten_lazy_band_dims(result)
        return result

    def _preserve_band_dims(self, arr: Any) -> Any:
        """Reshape an eager full read to the dimension-preserving `(*band_sizes, rows, cols)`.

        Undoes GDAL's row-major band flatten and `read_array`'s singleton squeeze, using the
        variable's tracked `_band_dim_names` / `_band_dim_sizes` — the same reshape
        :meth:`_materialize_variable_array` relies on, so the eager `squeeze=False` read and the
        lazy read return one shape. A variable with no band dimensions is returned unchanged.
        """
        if self._band_dim_names:
            if getattr(arr, "ndim", 0) == 2:
                arr = np.expand_dims(arr, axis=0)
            arr = unflatten_band_axes(arr, self._band_dim_names, self._band_dim_sizes)
        return arr

    def _flatten_lazy_band_dims(self, arr: Any) -> Any:
        """Flatten a lazy `(*band_sizes, rows, cols)` read to the classic `(bands, rows, cols)`.

        The row-major reshape matches GDAL's classic-raster band flatten, and a single resulting
        band axis is squeezed to 2-D, so the lazy `squeeze=True` read matches the eager read's
        shape. Arrays already 2-D (no band axis) are returned unchanged.
        """
        if getattr(arr, "ndim", 0) > 2:
            rows, cols = arr.shape[-2], arr.shape[-1]
            arr = arr.reshape(-1, rows, cols)
            if arr.shape[0] == 1:
                arr = arr.reshape(rows, cols)
        return arr

    def _read_non_raster_variable(
        self,
        variable: str,
        band: int | None,
        window: Any,
        chunks: Any,
        masked: bool,
        unpack: bool,
        values: LabeledArray,
    ) -> ArrayLike:
        """Return the values of a variable that has no raster plane.

        A 1-D array (a coordinate axis, a data series, a bounds array) or a
        non-numeric one has no raster plane. Several such names are
        *enumerated* -- GOES ABI declares `time_bounds` / `x_image_bounds` /
        `y_image_bounds` as data variables and a grouped store enumerates
        `"flight_03/CO"` -- so they reach `read_array` through the ordinary
        container route and used to fail there with
        `AttributeError: 'MDArray' object has no attribute 'read_array'`.

        The values are taken from the `LabeledArray` the caller already resolved
        rather than read again. `get_variable` materialises now, so re-reading
        here would have made every container-route read of such a variable read
        the whole array twice.

        Args:
            variable: The array name, used in the refusals below and to reopen
                the MDArray when `unpack` is set.
            band: Rejected -- an array with no raster plane has no bands.
            window: Rejected -- there is no raster window to clip.
            chunks: Rejected -- the lazy path builds a raster plane.
            masked: Rejected -- masking is defined against a band's no-data.
            unpack: Applied from the array's own CF `scale_factor` /
                `add_offset`, since there is no band to carry them.
            values: The variable `read_array` already materialised; its
                `.values` are what this returns.

        Returns:
            np.ndarray: The array's values, in storage order -- the very array
            `values` holds when `unpack` is `False`, not a copy of it.

        Raises:
            ValueError: If any raster-only argument is supplied.
        """
        rejected = [
            name
            for name, value in (
                ("band", band),
                ("window/bbox", window),
                ("chunks", chunks),
                ("masked", masked or None),
            )
            if value is not None
        ]
        if rejected:
            raise ValueError(
                f"read_array({', '.join(rejected)}) is not supported for "
                f"{variable!r}: GDAL exposes it as a plain array, not a raster "
                "(it is 1-D or non-numeric), so there is no band, window or "
                "chunk plane to apply. Read it without those arguments."
            )
        result = values.values
        if unpack:
            md_arr = open_mdarray(self._working_group(), variable)
            result = apply_unpack(
                result,
                None if md_arr is None else md_arr.GetScale(),
                None if md_arr is None else md_arr.GetOffset(),
            )
        return cast(ArrayLike, result)

    def _resolve_bbox_to_window(
        self,
        window: Any,
        bbox: tuple[float, float, float, float] | list[float] | None,
        epsg: Any,
        chunks: Any,
    ) -> Any:
        """Fold a ``bbox`` into a one-row FeatureCollection window (else pass ``window`` through).

        Building the FeatureCollection once here — and returning ``window``
        unchanged when no ``bbox`` is given — keeps the bbox/window/chunks
        guards from re-firing on the recursive container->subset call or on
        ``super().read_array``. Mirrors NetCDF.crop's "build mask once at the
        top" pattern.
        """
        is_pixel_window = (
            isinstance(window, (list, tuple))
            and len(window) == 4
            and all(
                isinstance(v, (int, float, np.integer, np.floating)) for v in window
            )
        )
        if bbox is None:
            if window is not None and chunks is not None and not is_pixel_window:
                # A geometry / polygon window cannot be a simple dask slice, so the lazy path still
                # refuses it; a plain pixel window `[xoff, yoff, xsize, ysize]` is supported (#1225)
                # and slices the lazy array in `_read_array_lazy_processed`.
                raise ValueError(
                    "read_array(chunks=..., window=...) supports a pixel window "
                    "[xoff, yoff, xsize, ysize]; for a geometry window read lazily and slice the "
                    "resulting dask array instead."
                )
            return window
        # The bbox/window exclusivity check stays here, ahead of the chunks and
        # no-CRS guards, so an invalid combination raises the same "not both"
        # message it always has (the shared resolve_read_window would otherwise
        # only see it last). resolve_read_window's own exclusivity guard is then
        # dormant below (window is None past this point).
        if window is not None:
            raise ValueError("read_array accepts either `window` or `bbox`, not both")
        if chunks is not None:
            raise ValueError(
                "read_array(chunks=..., bbox=...) is not supported; "
                "read lazily and slice the resulting dask array instead."
            )
        # `.epsg` is None for a no-EPSG CRS (e.g. geostationary); fall back to the
        # WKT so a bbox in the grid's own CRS is still honoured (#706).
        crs = epsg if epsg is not None else crs_spec(self.epsg, self.crs)
        if not crs:
            raise ValueError(
                "read_array(bbox=…) requires an explicit `epsg=` when the "
                "NetCDF has no CRS at all — a bbox without a CRS is ambiguous"
            )
        # The bbox -> FeatureCollection build is shared with Dataset.read_array via
        # the injected, already-resolved CRS.
        return resolve_read_window(window, bbox, crs=crs)

    @property
    def scale(self) -> list[float]:
        """Per-band factor a read applies -- the variable's own `_scale` when it has one.

        `Dataset.scale` reports each GDAL band's slot. A `NetCDF` variable can also carry
        its recipe in Python (`_scale` / `_offset`), which a read applies ahead of the
        band's, and `sel()` builds exactly that: a band declaring nothing with the recipe
        held only on the variable. Reporting the band there said `[1.0]` while every
        read was unpacked with 0.01, so this answers from `_effective_packing` -- the
        same resolver the reads use -- and the property and the values cannot disagree.

        Returns:
            list[float]: One factor per band; `1.0` where a band is not packed.

        Examples:
            - A selection holds its recipe only in Python, and still reports it:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import NetCDF, GeoReference
                >>> counts = np.arange(2 * 3 * 4, dtype="int16").reshape(2, 3, 4)
                >>> nc = NetCDF.from_array(
                ...     counts,
                ...     geo_ref=GeoReference(top_left_corner=(0.0, 3.0), cell_size=1.0, epsg=4326),
                ...     variable_name="t",
                ... )
                >>> variable = nc.get_variable("t")
                >>> variable._scale, variable._offset = 0.01, 1.5
                >>> selected = variable.sel(**{variable._band_dim_name: variable._band_dim_values[1]})
                >>> selected.raster.GetRasterBand(1).GetScale() is None
                True
                >>> selected.scale, selected.offset
                ([0.01], [1.5])

                ```
        """
        return [
            1.0 if scale is None else float(scale)
            for scale, _ in (
                self._effective_packing(index) for index in range(self.band_count)
            )
        ]

    @scale.setter
    def scale(self, value: list[float]) -> None:
        """Write each band's factor, keeping the variable's own recipe in step with it.

        The variable's `_scale` / `_offset` take precedence over the band, so writing the
        band alone would leave the old factor applied and the assignment looking like it
        did nothing. When the variable carries its own pair, its factor is updated to the
        one just written; the pair is treated as a unit by `_effective_packing`, so
        clearing only this half would let the surviving `_offset` outrank the band and
        drop the new factor. An MDArray holds one recipe for the whole variable, so the
        first band's value is the one it takes.

        Raises:
            ReadOnlyError: The variable is a read-only on-disk file.
        """
        Dataset.scale.fset(self, value)  # type: ignore[attr-defined]
        if self._scale is not None or self._offset is not None:
            self._scale = float(value[0])

    @property
    def offset(self) -> list[float]:
        """Per-band offset a read applies -- the variable's own `_offset` when it has one.

        See :attr:`scale` for why this answers from `_effective_packing` rather than from
        the GDAL band's slot.

        Returns:
            list[float]: One offset per band; `0.0` where a band is not packed.
        """
        return [
            0.0 if offset is None else float(offset)
            for _, offset in (
                self._effective_packing(index) for index in range(self.band_count)
            )
        ]

    @offset.setter
    def offset(self, value: list[float]) -> None:
        """Write each band's offset, keeping the variable's own recipe in step with it.

        See the :attr:`scale` setter for why the variable's pair is updated rather than
        cleared.

        Raises:
            ReadOnlyError: The variable is a read-only on-disk file.
        """
        Dataset.offset.fset(self, value)  # type: ignore[attr-defined]
        if self._scale is not None or self._offset is not None:
            self._offset = float(value[0])

    def _spend_packing(self) -> None:
        """Drop the variable's own `_scale` / `_offset` after an in-place compute.

        `_update_inplace` deliberately preserves them, which is right for `set_crs`,
        `change_no_data_value` and the `epsg` setter -- none of those touch the values.
        An in-place compute does: it writes physical values, and since
        `_effective_packing` prefers the variable's own pair, a pair left behind was
        applied to them a second time. `100 -> 2.5 -> 1.525`, the exact double
        application this module is otherwise careful to rule out.

        Called by `Analysis.apply` and `Analysis.fill` right after their
        `_update_inplace`. The raster swapped in declares no packing on its band, so
        once the pair is gone `_effective_packing` falls back to that band and finds
        nothing to apply.
        """
        self._scale = None
        self._offset = None

    def _effective_packing(self, band: int = 0) -> tuple[Any, Any]:
        """The `(scale, offset)` a read of this variable must apply.

        There are two places the packing can live and they do not always agree, so
        every read path resolves it here rather than picking one. The variable's own
        `_scale` / `_offset` win: `_preserve_netcdf_metadata` copies them onto every
        result, and the rest of this module -- the fan-out carry, the stream specs --
        treats them as the truth. The classic band's `GetScale` / `GetOffset` are the
        fallback, taken whenever the variable's own pair is unset, the identity or
        unusable -- which is what a container opened with
        `open_as_multi_dimensional=False` has, since nothing copies the MDArray's
        attributes onto the variable there, and what an in-place compute leaves
        behind once `_spend_packing` has dropped the pair.

        Resolving it in one place is the point. While the eager arm consulted the
        band and the lazy arm consulted only `_scale`, the same classic-opened
        variable read `2.5` eagerly and `100.0` through `chunks=`.

        Args:
            band: Zero-based band index, used only for the fallback -- an MDArray
                declares one packing for the whole variable.

        Returns:
            tuple: `(scale, offset)`, either of which may be `None` for "unset".
        """
        scale = getattr(self, "_scale", None)
        offset = getattr(self, "_offset", None)
        if _is_identity_packing(scale, offset):
            scale, offset = super()._effective_packing(band)
        return scale, offset

    def _read_array_eager(
        self,
        band: int | None,
        window: Any,
        masked: bool,
        bbox_rounding: str = "cover",
        unpack: bool = True,
    ) -> ArrayLike:
        """Eager (numpy) read through the Dataset mixin.

        Unpacking happens exactly once, and from the same pair the lazy path uses. A
        variable's MEM raster usually carries the CF packing on its band (`GetScale` /
        `GetOffset` answer the `scale_factor` / `add_offset` the MDArray declares), so
        forwarding `unpack` *and* applying it here multiplied the packing in twice --
        `100 -> 2.5 -> 1.525`, plausible and wrong.

        Which pair to apply is `_effective_packing`'s decision, shared with the lazy
        arm so the two cannot answer in different units. Falling through to
        `super()` with `unpack` untouched keeps the per-band case working for a
        variable that declares nothing of its own, since a raster may hold a
        different factor per band while an MDArray has just the one.
        """
        scale, offset = self._effective_packing()
        if unpack and not _is_identity_packing(scale, offset):
            raw = super().read_array(
                band=band,
                window=window,
                masked=masked,
                bbox_rounding=bbox_rounding,
                unpack=False,
            )
            return cast(ArrayLike, apply_unpack(raw, scale, offset))
        return cast(
            ArrayLike,
            super().read_array(
                band=band,
                window=window,
                masked=masked,
                bbox_rounding=bbox_rounding,
                unpack=unpack,
            ),
        )

    def _read_array_lazy(self, chunks: Any, lock: Any) -> ArrayLike:
        """The dask read, through `build_lazy_array`, once the request can be served.

        A lazy read reopens the variable from its store, so it needs a path and a name to
        reopen — and it needs the store to still hold the cells being asked for. A raster
        rebuilt in memory by `where`, `fillna`, `isnull` or `notnull` keeps the name it is
        called by, for labelling, but its values are its own; without the flag it carries
        that read reached for `file::name` and failed inside GDAL with a bare
        `No such file or directory`. Masking (`masked=True`) is applied by the caller
        (`read_array`) after unpack, via :func:`mask_no_data`.

        Args:
            chunks: The dask chunk specification.
            lock: The read lock handed to `build_lazy_array`.

        Returns:
            ArrayLike: The dask array.

        Raises:
            ValueError: There is no variable name to reopen — the receiver is a container
                rather than a variable — or the variable was rebuilt in memory, so the
                store no longer holds these cells. Read it eagerly instead.
        """
        parent = self._parent_nc if self._parent_nc is not None else self
        # Prefer the real /vsimem backing path over a cosmetic from_bytes `name=` that shadows
        # _file_name, so a named in-memory lazy read reopens the true source (#1058; same family as
        # #1050/#1053/#1057).
        source = getattr(parent, "_vsimem_path", None) or parent._file_name
        path = strip_netcdf_subdataset_prefix(source)
        var_name = self._source_var_name
        if var_name is None:
            raise ValueError(
                "Lazy read requires a variable name; pass "
                "`variable=` on the container or call read_array "
                "on a subset from `get_variable()`."
            )
        if self._rebuilt_in_memory:
            raise ValueError(
                f"Lazy read reopens the variable from its store, and {var_name!r} was "
                f"rebuilt in memory — by a cell-wise member such as where() or clip(), by "
                f"a cut such as sel() or head(), or by a crop or reprojection — so the "
                f"store no longer holds these cells in this shape and order. Read it "
                f"eagerly with read_array()."
            )
        # Thread the eager-resolved raster plane (and its flips) into the lazy build so a variable
        # whose latitude/longitude is not the trailing pair -- selected via `x_dim`/`y_dim` or CF
        # detection -- is oriented on the SAME plane the eager `get_variable` read produces, instead
        # of silently normalizing the trailing two axes (#728). `_md_spatial_dims` is `None` only for
        # a 1-D coordinate read, where `build_lazy_array` keeps the trailing behaviour.
        spatial_dims = self._md_spatial_dims
        flips = None
        if spatial_dims is not None:
            flips = (bool(self._md_y_flipped), bool(self._md_x_flipped))
        return cast(
            ArrayLike,
            build_lazy_array(
                path=path,
                variable_name=var_name,
                chunks=chunks,
                lock=lock,
                manager_hook=self._register_lazy_manager,
                spatial_dims=spatial_dims,
                flips=flips,
                # Each chunk task re-opens the store, so a signed remote NetCDF
                # needs its credentials shipped with the graph.
                gdal_env=self._gdal_env or None,
            ),
        )

    def _register_lazy_manager(self, manager: Any) -> None:
        """Track (weakly) a lazy read's file manager so `close()` can release its handle (#727).

        Registered on both this object and its root container (`_parent_nc`), so closing either a
        `get_variable()` subset or the root releases the handle. A `weakref.WeakSet` is used
        deliberately: the manager's drop-time finalizer must still fire when the lazy array is dropped
        (so tracking must not keep the manager alive), and the set auto-prunes collected managers so a
        long-lived container in a read loop does not accumulate dead entries.
        """
        with _LAZY_MANAGERS_LOCK:
            for owner in (self, self._parent_nc):
                if owner is None:
                    continue
                managers = getattr(owner, "_lazy_managers", None)
                if managers is None:
                    managers = weakref.WeakSet()
                    owner._lazy_managers = managers
                managers.add(manager)

    def _release_lazy_managers(self) -> None:
        """Close every still-alive tracked lazy manager, releasing its parked handle.

        The set is not cleared: `manager.close()` is idempotent, and a still-alive manager stays
        tracked so a handle it re-parks (a `compute()` after `close()`) is released by a later
        `close()` too. Dead managers drop out of the `WeakSet` on their own.
        """
        with _LAZY_MANAGERS_LOCK:
            managers = list(getattr(self, "_lazy_managers", ()))
        for manager in managers:
            manager.close()

    def _persist_to(self, path: str | Path | None) -> NetCDF:
        """Write this result to `path` and hand back a file-backed reopen.

        The spatial operations that build an in-memory result honour a `path`
        argument the same way: write it out, drop the in-memory handle, and
        reopen the file. `path=None` returns `self` untouched.

        The write is wrapped so the handle is released even when it fails.
        Three of the call sites this replaces closed only on success, so a
        failed write -- a full disk, a locked file -- leaked the handle and, on
        Windows, left the target locked against the retry.

        The reopen goes through `type(self)` rather than a hard-coded `NetCDF`,
        which is what one of the call sites this replaced already did. That
        routes the lookup through the subclass, so an override of `read_file`
        is honoured -- but it does **not** mean a subclass gets an instance of
        itself back: `NetCDF.read_file` ends in `return Container(...)` and
        ignores `cls`, so persisting a `Variable` yields a `Container`, exactly
        as it did before. Do not read the `type(self)` here as a
        same-class guarantee.

        Args:
            path: Destination to persist to, or `None` to stay in memory.

        Returns:
            NetCDF: `self` when `path` is `None`, else a file-backed reopen --
                a `Container`, whatever the receiver's class, per above.
        """
        if path is None:
            result: NetCDF = self
        else:
            try:
                self.to_file(str(path))
            finally:
                self.close()
            result = cast("NetCDF", type(self).read_file(str(path)))
        return result

    def _preserve_netcdf_metadata(self, result: Dataset) -> NetCDF:
        """Wrap a Dataset result as a NetCDF, preserving variable-subset metadata.

        Some operations on a variable subset build their result as a plain `Dataset` — a
        crop, the `sel` / `isel` cut — and this helper re-wraps it as a `Variable`. Others
        (`to_crs`, `resample`, `warped_view`) already return this class, and that object is
        labelled in place and returned. Either way the variable-specific attributes are
        copied over so that methods like `sel()`, `read_array(unpack=True)`, and further
        spatial operations continue to work with consistent return types.

        The canonical multi-band-dim fields (`_band_dim_names`,
        `_band_dim_values_map`, `_band_dim_sizes`) are propagated, and the
        legacy single-band-dim view (`_band_dim_name`, `_band_dim_values`) is
        re-derived from them via :meth:`_derive_primary_band_view` against the
        wrapped result's live band count. That helper is the single place the
        staleness guard lives: it nullifies the primary coordinate values when
        they are provably stale for the new band count (e.g. after a
        band-shrinking operation outside `sel()`).

        The coordinate lists are copied, not shared (`copy_band_values_map`), and the band
        dimensions' units are resolved into `_band_dim_time_attrs`, so the result still
        decodes its time stamps.

        Args:
            result: The `Dataset` (or `NetCDF`) returned by a parent
                spatial operation.

        Returns:
            NetCDF: The same data wrapped as a `NetCDF` with all
                variable-subset metadata preserved.

        See Also:
            `sel`: produces results that flow through this helper to
                keep the multi-band-dim metadata consistent across
                spatial ops.
        """
        if isinstance(result, NetCDF):
            wrapped = result
        else:
            wrapped = Variable(
                result._raster,
                access=result._access,
                open_as_multi_dimensional=False,
            )
        wrapped._is_md_array = self._is_md_array
        wrapped._is_subset = self._is_subset
        wrapped._band_dim_names = self._band_dim_names
        wrapped._band_dim_sizes = self._band_dim_sizes
        wrapped._band_dim_values_map = copy_band_values_map(self._band_dim_values_map)
        # Re-derive the legacy primary-dim view from the canonical fields against the
        # wrapped result's live band count: a band-shrinking spatial op may have
        # diverged the band count from the cached coords, so the staleness guard lives
        # once in `_derive_primary_band_view` rather than being repeated here.
        wrapped._band_dim_name, wrapped._band_dim_values = (
            self._derive_primary_band_view(
                wrapped._band_dim_names,
                wrapped._band_dim_values_map,
                wrapped._band_dim_sizes,
                wrapped._band_count,
            )
        )
        wrapped._variable_attrs = self._variable_attrs
        wrapped._scale = self._scale
        wrapped._offset = self._offset
        wrapped._parent_nc = self._parent_nc
        wrapped._source_var_name = self._source_var_name
        wrapped._band_dim_time_attrs = self._resolved_band_dim_time_attrs()
        # A wrapped subset reopens the parent file on unpickle, so it must carry
        # the parent's captured GDAL open options too (#1025).
        wrapped._open_options = self._open_options
        wrapped._gdal_md_arr_ref = None
        wrapped._gdal_rg_ref = None
        # Everything routed here holds cells this process assembled — a `sel`/`isel` cut and
        # the Tier 2 band members through `_subset_along_dim`, `squeeze`/`expand_dims`
        # through `_relabelled`, a crop, a reprojection, a resample. None of them still read
        # as `parent_file::source_var_name`, which is all the lazy path knows how to reopen,
        # so `read_array(chunks=)` answered the whole, unselected store variable: a `tail`
        # of one step read back as four, and a `sortby` came back unsorted. The flag makes
        # that path refuse in words, exactly as it already does for `where()`/`fillna()`.
        wrapped._rebuilt_in_memory = True
        return wrapped

    def crop(self, *args, **kwargs) -> NetCDF:
        """Facade — :meth:`Selection.crop <pyramids.netcdf.engines.selection.Selection.crop>`."""
        return self.selection.crop(*args, **kwargs)

    @staticmethod
    def _bbox_geotransform(lon: np.ndarray, lat: np.ndarray) -> tuple:
        """Approximate north-up affine geotransform spanning the 2-D coords' bounding box.

        A curvilinear grid has no true affine transform; this gives a cropped curvilinear result a
        sensible ``total_bounds`` (its lon/lat envelope). The authoritative per-cell mapping is the
        2-D ``_curvilinear_coords`` carried alongside. The 2-D coordinates are cell *centres*, so the
        cell size is the spacing between adjacent centres (``cols - 1`` gaps across ``cols`` centres),
        and the north-west origin sits half a cell beyond the outermost centre — so the envelope spans
        the full ``cols x rows`` cells, not just centre-to-centre. Pixel height is negative (north-up).

        Args:
            lon (np.ndarray): 2-D longitude array of the (windowed) grid; finite values define the
                west/east extent.
            lat (np.ndarray): 2-D latitude array of the same shape; finite values define the
                south/north extent. Its shape sets the row/column counts.

        Returns:
            tuple: A GDAL geotransform ``(x_origin, x_cell, 0.0, y_origin, 0.0, -y_cell)`` where the
                origin is the north-west envelope corner (half a cell outside the corner centre).

        Examples:
            - A 2x2 grid of centres at lon 10/20 and lat 30/40 has 10deg cells; the envelope origin
              sits half a cell (5deg) outside the corner centre:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import NetCDF
                >>> lon = np.array([[10.0, 20.0], [10.0, 20.0]])
                >>> lat = np.array([[40.0, 40.0], [30.0, 30.0]])
                >>> NetCDF._bbox_geotransform(lon, lat)
                (5.0, 10.0, 0.0, 45.0, 0.0, -10.0)

                ```
        """
        rows, cols = lat.shape
        lon_min, lon_max = float(np.nanmin(lon)), float(np.nanmax(lon))
        lat_min, lat_max = float(np.nanmin(lat)), float(np.nanmax(lat))
        # cell size = spacing between adjacent cell centres (cols-1 gaps); a single row/column has no
        # spacing to measure, so fall back to 0 (a degenerate 1-cell envelope, as before).
        x_cell = (lon_max - lon_min) / (cols - 1) if cols > 1 else 0.0
        y_cell = (lat_max - lat_min) / (rows - 1) if rows > 1 else 0.0
        return (lon_min - x_cell / 2, x_cell, 0.0, lat_max + y_cell / 2, 0.0, -y_cell)

    def _variable_is_spatial(self, rg: Any, var_name: str) -> bool:
        """True when ``var_name`` is a gridded variable (can be cropped / reprojected).

        A variable is spatial when it has a raster plane -- at least two
        dimensions and a numeric dtype (`_has_raster_plane`) -- and a recognised
        ``(y, x)`` pair among **its own** dimensions — detected via the CF-attribute
        / well-known-name machinery (:meth:`_cf_spatial_axes` /
        :meth:`_named_spatial_axes`), per variable, so a variable on a secondary
        grid is judged on its own axes rather than one container-wide pair. The
        check reads the MDArray's declaration -- dimensions and dtype -- directly,
        so an auxiliary variable is identified before any spatial op runs and
        without reading it. `get_variable` could not answer the question: it reads
        a non-raster variable in full, and it builds a raster from any 2-D numeric
        array, spatial axes or not.

        Args:
            rg: The root :class:`osgeo.gdal.Group` of the open store, used to
                open the named array and resolve its dimensions.
            var_name: Name of the variable to classify.

        Returns:
            bool: `True` when the variable is numeric with at least two
            dimensions and a recognised `(y, x)` pair among them; `False` for a
            1-D / scalar auxiliary variable, a 2-D variable with no spatial axes,
            a string or compound array whatever its axes, or a name that cannot
            be opened as an MDArray.

        Examples:
            - A gridded ``t2m(valid_time, lat, lon)`` variable is spatial, so
              ``crop`` / ``to_crs`` will operate on it (requires an open store):
                ```python
                >>> rg = nc._raster.GetRootGroup()  # doctest: +SKIP
                >>> nc._variable_is_spatial(rg, "t2m")  # doctest: +SKIP
                True

                ```
            - A 1-D ``number(valid_time)`` auxiliary variable is not spatial, so
              it is carried through unchanged instead:
                ```python
                >>> rg = nc._raster.GetRootGroup()  # doctest: +SKIP
                >>> nc._variable_is_spatial(rg, "number")  # doctest: +SKIP
                False

                ```
        """
        md = open_mdarray(rg, var_name)
        if md is None:
            return False
        var_dims = [d.GetName() for d in md.GetDimensions()]
        if not _has_raster_plane(md):
            # A string or compound array sitting on recognised (y, x) axes has
            # the dimensions of a grid but no raster plane: `get_variable` cannot
            # build one, so treating it as spatial handed it to the fan-out,
            # which then refused the *whole* container's crop / to_crs / reduce
            # over one auxiliary field. It goes to the in-memory arm's auxiliary
            # carry (`_carry_aux_variables`) instead, which copies it when it can
            # and otherwise drops it with a warning naming it: GDAL's Python
            # `Write` refuses a string array of rank >= 2, and
            # `numpy_to_gdal_dtype` has no GDAL type for a compound's structured
            # dtype, although GDAL itself writes a compound from Python fine.
            return False
        return (
            self._cf_spatial_axes(rg, var_dims) is not None
            or self._named_spatial_axes(var_dims) is not None
        )

    def _spatial_variable_names(self, rg: Any = None) -> list[str]:
        """Names of the container's gridded variables (have a recognised (y, x) pair).

        Used by every spatial fan-out (``crop`` / ``to_crs`` / ``resample`` /
        ``reduce``) so they act only on griddable variables; the remaining
        non-spatial auxiliary variables are carried through by
        :meth:`_carry_aux_variables`.

        Args:
            rg: An already-resolved root :class:`osgeo.gdal.Group`. When ``None``
                (the default) the root group is fetched from the wrapped dataset;
                callers that already hold it pass it in to avoid a redundant
                ``GetRootGroup()``.

        Returns:
            list[str]: Names of the gridded variables, in declaration order.
            Empty when the store has no root group (e.g. a closed or
            single-variable raster handle) or no variable carries a ``(y, x)``
            pair.

        Examples:
            - An ERA5-shaped container reports only its gridded variables,
              leaving the 1-D ``number`` auxiliary out (requires an open store):
                ```python
                >>> nc._spatial_variable_names()  # doctest: +SKIP
                ['t2m']

                ```
            - A single-variable view (no root group) yields an empty list:
                ```python
                >>> nc.get_variable("t2m")._spatial_variable_names()  # doctest: +SKIP
                []

                ```
        """
        if rg is None:
            rg = self._working_group()
        if rg is None:
            names: list[str] = []
        else:
            # Scanned over the *readable* names, not the enumeration. Whether an
            # array is gridded is a question about its axes, and CF ancillary
            # arrays -- GOES ABI's `DQF` quality flags, an uncertainty layer, a
            # `status_flag` -- sit on the same y/x grid as the data they qualify.
            # Filtering the narrow enumeration left them in neither list: not
            # transformed here, and -- under the dimension filter
            # `_carryable_aux_names` carried at the time, since removed -- not
            # carried either, so a container-wide `to_crs` or `crop` dropped
            # them from the output entirely, without a warning.
            names = [
                n
                for n in self._readable_variable_names(rg)
                if self._variable_is_spatial(rg, n)
            ]
        return names

    def _variable_dim_names(self, rg: Any, var_name: str) -> list[str]:
        """Return a variable's dimension names, or ``[]`` if it can't be opened.

        Args:
            rg: The store's root :class:`osgeo.gdal.Group`.
            var_name: Name of the variable (MDArray) to inspect.

        Returns:
            list[str]: The variable's dimension names in storage order, or an
            empty list when the variable is missing or unreadable.
        """
        md = open_mdarray(rg, var_name)
        if md is None:
            return []
        return [d.GetName() for d in md.GetDimensions()]

    def _carryable_aux_names(self, rg: Any, spatial_vars: list[str]) -> list[str]:
        """Arrays an operation must carry through untouched.

        Everything readable that is not one of the `spatial_vars` being
        transformed. There is no second filter: every array the store can hand
        back is either rewritten by the operation or copied through it, and the
        two lists partition the readable set between them.

        The readable set is used rather than :attr:`variable_names` because the
        CF non-data arrays that property leaves out -- an ancillary `expver`, a
        scalar flag, a `lat_bnds` -- still have to survive a crop; dropping them
        would make the operation lossy.

        An earlier rule *did* filter further, excluding anything indexed by a
        dimension the operation reshapes. Its purpose was to stop a
        `lat_bnds(lat, nv)` being copied verbatim into a cropped result, where
        it still describes the **source's** latitude axis rather than the
        result's. Commit `0c6aa32b5` removed that filter deliberately, because
        it was over-broad: a WRF store's staggered `U` / `V` and a CAM store's
        `gw` share a reshaped dimension with the gridded variables and vanished
        from the output with no warning. The trade is made knowingly -- a stale
        bounds array is visible and correctable, a silently missing variable is
        neither -- and the result now also carries in the source coordinate
        axes those bounds are indexed by
        (:meth:`_carry_aux_dimension_coordinates`), so a carried `lat_bnds`
        lands beside the `lat` it describes instead of on a bare dimension.
        Both halves of that trade are pinned by
        `tests/netcdf/structure/test_variable_names_invariance.py`.

        Args:
            rg: The store's root :class:`osgeo.gdal.Group`. The readable set is
                taken against this group rather than a freshly resolved one, so
                the split is decided against the same group the caller resolved
                `spatial_vars` against.
            spatial_vars: The gridded variables the operation transforms.

        Returns:
            list[str]: Names to copy through unchanged.
        """
        return [n for n in self._readable_variable_names(rg) if n not in spatial_vars]

    @staticmethod
    def _group_relative_name(rg: Any, md_arr: Any) -> str:
        """An array's name as ``rg`` resolves it, group-qualified where it has to be.

        ``MDArray.GetName()`` is the **leaf**, and every name that reaches
        :func:`~pyramids.netcdf._mdim.open_mdarray` is read relative to the
        group it is given. For an array in a sub-group the two differ, and the
        leaf then resolves at the root -- to a *different* array whenever one
        of that name exists there. That is what made a sub-group auxiliary's
        dimension coordinate come back carrying the root's values: a store with
        `n = [100, 200, 300]` at the root and `g/n = [1, 2, 3]` beside
        `g/aux(n)` copied the root's `n` in next to `g/aux`, so the carried
        array was labelled with another variable's axis. Where the root has no
        array of that name the coordinate resolved to nothing instead and was
        dropped without a word.

        The full name is relative to the *file* root, so it is trimmed back to
        ``rg`` -- a ``get_group(...)`` view resolves names inside the group it
        opened, not from the file root. An array outside ``rg``'s subtree
        (a dimension inherited from a parent group, seen from a group view)
        cannot be named relative to it at all; the leaf is returned, which is
        what this always answered and is the only name that has a chance.

        Args:
            rg: The group the returned name will be resolved against.
            md_arr: The :class:`osgeo.gdal.MDArray` to name.

        Returns:
            str: The array's name relative to ``rg``.
        """
        leaf = str(md_arr.GetName())
        full = str(md_arr.GetFullName() or leaf)
        prefix = str(rg.GetFullName() or "/").rstrip("/") + "/"
        return full[len(prefix) :] if full.startswith(prefix) else leaf

    def _aux_dimension_coordinate_names(
        self, rg: Any, aux_vars: list[str], taken: set[str]
    ) -> list[str]:
        """Coordinate arrays the carried auxiliaries are indexed by, and the result lacks.

        The name-resolution half of :meth:`_carry_aux_dimension_coordinates`,
        which documents why they have to be carried at all. A coordinate the
        result already holds is left alone: its `time` axis is the transformed
        one, not the source's.

        Names are resolved through :meth:`_group_relative_name`, so a
        sub-group's coordinate is named `g/n` rather than `n` -- the leaf
        resolves at the root, against a different array.

        Args:
            rg: The source store's root group, or ``None`` in classic mode.
            aux_vars: The auxiliary variables being carried through.
            taken: Array names the result already holds, which must not be
                shadowed -- the result's own `time` axis is the transformed
                one, not the source's.

        Returns:
            list[str]: Source coordinate-array names to carry in ahead of the
            auxiliaries, in first-seen order and each named once.
        """
        names: list[str] = []
        # The result's group is flat and `add_variable` copies under the leaf
        # segment, so `taken` and the first-seen check are both decided on the
        # leaf: two sub-groups' `n` axes cannot both land, and carrying the
        # second as `n-new` would name an axis nothing is indexed by.
        leaves: set[str] = set()
        for var_name in aux_vars:
            md_arr = open_mdarray(rg, var_name) if rg is not None else None
            for dim in md_arr.GetDimensions() if md_arr is not None else []:
                name = NetCDF._carryable_axis_name(rg, dim, aux_vars, taken, leaves)
                if name is not None:
                    names.append(name)
                    leaves.add(name.rsplit("/", 1)[-1])
        return names

    @staticmethod
    def _carryable_axis_name(
        rg: Any,
        dim: Any,
        aux_vars: list[str],
        taken: set[str],
        leaves: set[str],
    ) -> str | None:
        """The name of the axis `dim` is indexed by, when the result should carry it.

        Split out of the walk above, which was making this decision inline over
        a nested loop and reading as one function doing two jobs.

        Args:
            rg: The source's working group, which `name` is made relative to.
            dim: The dimension whose indexing variable is being considered.
            aux_vars: The auxiliaries being carried; their own names are not
                also carried as axes.
            taken: Names the result already holds.
            leaves: Leaf names already chosen on this walk.

        Returns:
            str | None: The group-relative name to carry, or `None` when the
                dimension has no indexing variable, the name is already
                accounted for, or it is a spatial axis.
        """
        indexing = dim.GetIndexingVariable()
        if indexing is None:
            return None
        name = NetCDF._group_relative_name(rg, indexing)
        leaf = name.rsplit("/", 1)[-1]
        if leaf in taken or leaf in leaves or name in aux_vars:
            return None
        # A spatial axis describes the *source* grid, and the result's grid is
        # not the source's after a reprojection, a resample or a crop. Copying
        # `lat`/`lon` across put the source's degrees into an EPSG:3857
        # container -- and `_compute_geotransform` prefers a lon/lat pair over
        # the stored transform, so the container then reported a geotransform
        # and a bbox in degrees while declaring metres. The result derives its
        # own spatial axes; only the non-spatial ones are the source's to give.
        return None if leaf.lower() in AXIS_NAMES else name

    def _carry_aux_dimension_coordinates(
        self, result: NetCDF, aux_vars: list[str], operation: str
    ) -> None:
        """Copy in the coordinate arrays the carried auxiliaries are indexed by.

        A carried array brings its dimensions with it but not the coordinate
        variables describing them, so a `lat_bnds(lat, bnds)` landed in a result
        holding a bare `lat` dimension and no `lat` array. That is a loss on its
        own, and it also split the two arms of one call: the streaming writer
        *does* carry those coordinates (:meth:`_add_aux_var_spec`), so the CF
        `bounds` attribute survived a ``crop(path=...)`` and not a ``crop()``,
        and the same file then classified `lat_bnds` as a bounds array on one
        arm and as a data variable on the other -- leaving `variable_names`
        different between two spellings of one call (round-4 M4).

        Spatial axes are deliberately *not* carried, which is the one place
        this stops short of what the streaming writer does. A `lat`/`lon` pair
        describes the grid it was written for, and every operation that reaches
        here has changed that grid; carrying them made a reprojected container
        report the source CRS's geotransform and bbox, because
        :meth:`_compute_geotransform` reads a lon/lat pair in preference to the
        stored transform. A `lat_bnds` whose `lat` is therefore missing is the
        lesser of the two: stale metadata beside the array, against silently
        wrong georeferencing on the container itself. The arms-agree test did
        not catch it because it compares `get_variable(...).geotransform`, not
        the container's.

        Args:
            result: The container the fan-out built, mutated in place.
            aux_vars: The auxiliary variables that were carried through.
            operation: Operation name, for the warning message.

        Returns:
            None: ``result`` gains one array per missing coordinate. Copy
            failures are reported the same way :meth:`_carry_aux_variables`
            reports its own.
        """
        result_rg = result._working_group()
        taken = (
            set(result_rg.GetMDArrayNames() or []) if result_rg is not None else set()
        )
        names = self._aux_dimension_coordinate_names(
            self._working_group(), aux_vars, taken
        )
        if names:
            self._carry_aux_variables(result, names, operation)

    def _carry_aux_variables(
        self, result: NetCDF, aux_vars: list[str], operation: str
    ) -> None:
        """Copy non-spatial auxiliary variables into ``result`` unchanged.

        They can't be cropped/reprojected/reduced through the raster path but must
        survive the op, so each is copied verbatim (dims / values / attrs) via
        :meth:`add_variable`. Best-effort: a copy failure for one variable warns
        rather than failing the whole operation.

        The coordinate arrays those auxiliaries are indexed by are carried by
        :meth:`_carry_aux_dimension_coordinates`, which the fan-out calls right
        after this.

        Args:
            result: The container built from the spatial variables.
            aux_vars: Names of the non-spatial variables to carry through.
            operation: Operation name, for the warning message.

        Returns:
            None: ``result`` is mutated in place — each carried variable is
            added to it. A copy failure for one variable emits a
            :class:`UserWarning` naming that variable and continues.

        Examples:
            - Carry an ERA5 cube's 1-D ``number`` auxiliary into a freshly
              cropped result so it survives the op (requires an open store):
                ```python
                >>> cropped = nc._apply_to_all_variables("crop", {"mask": mask})  # doctest: +SKIP
                >>> nc._carry_aux_variables(cropped, ["number"], "crop")  # doctest: +SKIP
                >>> sorted(cropped.variable_names)  # doctest: +SKIP
                ['number', 't2m']

                ```
        """
        dropped: list[tuple[str, Exception]] = []
        for var_name in aux_vars:
            try:
                # `result` is the private container the fan-out built; nothing else
                # references its raster yet, so carry each aux var in place (copy=False)
                # to avoid a full MEM copy per aux variable (#143).
                result.add_variable(self, var_name, copy=False)
            except (RuntimeError, TypeError, ValueError) as exc:
                # `TypeError` too: a string array with a NULL entry reads back
                # with `None` in it, and writing that list raises "sequence must
                # contain strings". Uncaught, one such auxiliary failed the whole
                # container operation rather than being dropped with a warning
                # like every other variable that cannot be carried.
                dropped.append((var_name, exc))
        if dropped:
            # One aggregated warning naming every dropped variable, so a silent
            # data loss across crop/to_crs/resample/reduce is hard to miss in a
            # pipeline rather than scattered across per-variable warnings.
            names = ", ".join(repr(name) for name, _ in dropped)
            reasons = "; ".join(f"{name!r}: {exc}" for name, exc in dropped)
            warnings.warn(
                f"{operation}() could not carry {len(dropped)} non-spatial "
                f"variable(s) ({names}) into the result: {reasons}",
                stacklevel=3,
            )

    def _stream_apply_to_file(
        self, operation, op_kwargs, spatial_vars, aux_vars, path
    ) -> NetCDF | None:
        """Stream ``operation`` over every variable straight to a netCDF ``path``, slab by slab.

        Applies the op to each spatial variable (a cheap VRT/warp -- no data read), builds the
        output file's dimensions / coordinates / variable specs from the shared transformed grid,
        then fills each variable one leading-dimension slab at a time via
        :func:`open_streaming_multidim_netcdf`, so the whole cube is never resident. Non-spatial
        auxiliary variables are carried through unchanged (written whole).

        Returns ``None`` -- signalling the caller to fall back to the eager build-then-write path --
        for shapes this single-leading-dimension slab writer cannot represent: a spatial variable
        with two or more band dimensions, a string/object-typed auxiliary variable, or a
        curvilinear (2-D) coordinate grid.

        Args:
            operation: The Dataset method to apply (e.g. ``"crop"``).
            op_kwargs: Keyword arguments for that method.
            spatial_vars: Names of the variables carrying both spatial axes.
            aux_vars: Names of the non-spatial variables to carry through unchanged.
            path: Output ``.nc`` path.

        Returns:
            NetCDF | None: A file-backed container reading ``path``, or ``None`` when the shape is
            not slab-streamable (the caller then falls back to the eager path).

        Raises:
            ValueError: A name in `spatial_vars` has no raster plane, refused by name through
                `_require_raster_variable`. `_variable_is_spatial` keeps such a name out of
                `spatial_vars`, so this is a guard rather than an expected outcome.
        """
        rg = self._working_group()
        # `get_variable` builds a fresh classic-raster wrapper (a new GDAL dataset) each call, so
        # resolve every spatial variable once and reuse it across the guard/spec/write passes below
        # instead of re-opening it up to four times (review R2-N1).
        # Through `_require_raster_variable`, like every other fan-out site: the
        # streaming path was the one that still called `get_variable` directly,
        # so a non-raster variable reaching it died on `_band_dim_names` rather
        # than refusing by name. `_variable_is_spatial` now keeps such a variable
        # out of `spatial_vars`; this makes the guarantee local instead of
        # depending on a classifier two calls away.
        spatial_var_objs = {
            name: self._require_raster_variable(name) for name in spatial_vars
        }
        if not self._stream_feasible(rg, spatial_var_objs, spatial_vars, aux_vars):
            return None

        results = {
            name: getattr(spatial_var_objs[name], operation)(**op_kwargs)
            for name in spatial_vars
        }
        template = results[spatial_vars[0]]
        y_coord = np.asarray(template.y)
        x_coord = np.asarray(template.x)
        if y_coord.ndim != 1 or x_coord.ndim != 1:
            return None  # curvilinear (2-D) coords aren't slab-streamable here

        # Every variable is written against the template's shared y/x grid. A container whose
        # variables transform onto *different* grids (mixed native cell size / extent, or a
        # reprojection that yields per-variable shapes) isn't slab-streamable here — fall back to
        # the eager per-variable build rather than mis-writing a slab into a template-shaped array
        # (review L1).
        template_yx = (int(y_coord.shape[0]), int(x_coord.shape[0]))
        for name in spatial_vars:
            res_yx = (
                int(np.asarray(results[name].y).shape[0]),
                int(np.asarray(results[name].x).shape[0]),
            )
            if res_yx != template_yx:
                return None

        dims: dict[str, int] = {"y": int(y_coord.shape[0]), "x": int(x_coord.shape[0])}
        # The same CF attributes the eager arm stamps on its axes through
        # `_create_dimension`. Written as empty dicts, the streamed file's `x`
        # and `y` carried no `units`, `axis`, `standard_name` or `long_name` at
        # all, so `to_crs(4326)` and `to_crs(4326, path=...)` produced one file
        # whose longitudes said `degrees_east` and one that said nothing --
        # and a CF reader has no way to tell degrees from metres on the second.
        is_geographic = self._axes_are_geographic(template)
        coords: dict[str, tuple[np.ndarray, dict[str, Any]]] = {
            "y": (y_coord, build_coordinate_attrs("y", is_geographic)),
            "x": (x_coord, build_coordinate_attrs("x", is_geographic)),
        }
        var_specs: dict[str, tuple[tuple[str, ...], Any, dict[str, Any]]] = {}
        for name in spatial_vars:
            self._add_spatial_var_spec(
                name, spatial_var_objs[name], results[name], rg, dims, coords, var_specs
            )
        aux_data: dict[str, np.ndarray] = {}
        for name in aux_vars:
            self._add_aux_var_spec(
                name, rg, dims, coords, var_specs, aux_data, aux_vars
            )

        root_attrs = self._stream_root_attrs(template)

        with _interop.open_streaming_multidim_netcdf(
            str(path),
            dims,
            coords,
            var_specs,
            root_attrs,
            crs_wkt=root_attrs.get("crs_wkt"),
        ) as writer:
            for name in spatial_vars:
                self._write_stream_variable(
                    writer, name, spatial_var_objs[name], results[name]
                )
            for name in aux_vars:
                if name in aux_data:
                    writer.write_whole(name, aux_data[name])

        return NetCDF.read_file(str(path))

    @staticmethod
    def _stream_feasible(rg, spatial_var_objs, spatial_vars, aux_vars) -> bool:
        """True when the single-leading-dimension slab writer can represent every variable's shape.

        Rejects a spatial variable with two or more band dimensions and a string/object-typed
        auxiliary variable — shapes the writer cannot handle, for which the caller falls back to the
        eager build.
        """
        for name in spatial_vars:
            if len(spatial_var_objs[name]._band_dim_names) >= 2:
                return False
        for name in aux_vars:
            # Through the resolver: an aux name from the readable enumeration may
            # be group-qualified, which `OpenMDArray` alone does not walk.
            src_md = open_mdarray(rg, name)
            if src_md is not None and src_md.GetDataType().GetClass() in (
                gdal.GEDTC_STRING,
                gdal.GEDTC_COMPOUND,
            ):
                # Compound as well as string: the streamed write goes through
                # `numpy_to_gdal_dtype`, which has no mapping for a structured
                # dtype, so a compound auxiliary failed `to_crs(path=...)` with
                # "numpy data type is not supported" while the in-memory arm
                # dropped it with a warning. Declining here sends it to that arm.
                return False
        return True

    @staticmethod
    def _add_spatial_var_spec(name, var, res, rg, dims, coords, var_specs) -> None:
        """Add one spatial variable's band dimension / coordinate and its var-spec to the builders.

        `attrs` carries the source variable's own no-data sentinel (when it declared one), so a
        variable that actually has no-data keeps it per-variable; the template's no-data is
        additionally recorded as the global `nodata`. (Writing `scalar_no_data(res.no_data_value)`
        unconditionally was rejected — it surfaces GDAL's uninitialised default as a spurious
        out-of-range `_FillValue` for no-data-less vars, so the value is read off the *source
        array's* own slot instead.)

        The packing is added back by hand. GDAL lifts `scale_factor` / `add_offset` out of the
        attribute dictionary into the MDArray's own scale and offset slots, so `_read_attributes`
        never returns them — and the slab written here is raw, because `_write_stream_variable`
        reads with `unpack=False` for exactly this reason. Without them the file declares no
        packing and every value reads back shifted by the packing factor. The eager fan-out
        carries the same two through `SetScale` / `SetOffset`;
        this arm writes CF attributes because that is what the netCDF writer takes, and GDAL lifts
        them again on the next read. The no-data sentinel is lifted the same way, which is why it
        is put back here too.

        **How far the two arms actually agree.** Measured over `to_crs(4326)` taken both ways on
        six fixtures: no-data, coordinate dtype, coordinate CF attributes, the carried auxiliary
        axes' units, packing and per-variable attributes all match, and both arms place the result
        on the same grid. Two differences remain, deliberately:

        * the first band dimension's coordinate array is written `Float64` by the eager arm and in
          the source's own integer dtype here — the values are identical, so nothing is lost;
        * the streamed file keeps an extra per-variable `nodata` attribute beside the real
          `_FillValue`, because that key is the writer's own round-trip channel (#1061) and is what
          makes it call `SetNoDataValueDouble` at all.

        `tests/netcdf/structure/test_fan_out_arms_agree.py` pins the agreements and records both
        remaining differences.
        """
        # A spatial name comes from the readable enumeration, so it may be
        # group-qualified; the resolver walks the path, `OpenMDArray` does not.
        src_md = open_mdarray(rg, name)
        attrs = _read_attributes(src_md) if src_md is not None else {}
        if getattr(var, "_scale", None) is not None:
            attrs["scale_factor"] = var._scale
        if getattr(var, "_offset", None) is not None:
            attrs["add_offset"] = var._offset
        # The no-data sentinel is lifted out of the attribute dictionary into
        # the MDArray's own slot, exactly like `scale_factor` / `add_offset`
        # above, so `_read_attributes` never returns it -- and the streamed file
        # then declared no fill at all for a variable whose source declares one
        # (20 of the 22 eager/stream divergences measured in review round 4:
        # every ERA5 variable, `tos`, `CMI`, `DQF`). The key is `nodata`, which
        # is what `open_streaming_multidim_netcdf` turns into a real
        # `SetNoDataValueDouble` before the first slab; a plain `_FillValue`
        # entry is written as an ordinary attribute and read back as 0.0, which
        # is worse than none.
        #
        # The value is read off the **source array's** slot rather than
        # `res.no_data_value`, because the source's own declaration is the
        # thing being carried; `res` reports a value derived through the warp.
        # The two were measured across the corpus and never disagree -- 18
        # variable pairs, the only difference a `nan != nan` artefact -- and on
        # the no-fill fixture the warped wrapper does not invent a default
        # either. So this is a choice of the more direct source, not a
        # correction of a divergence: an earlier comment here claimed the
        # wrapper stamps GDAL's uninitialised default on every no-data-less
        # variable, and that is not observable on any fixture in the suite.
        src_ndv = src_md.GetNoDataValue() if src_md is not None else None
        if src_ndv is not None and "nodata" not in attrs:
            attrs["nodata"] = src_ndv
        band_dims = var._band_dim_names
        if not band_dims:
            var_specs[name] = (("y", "x"), np.dtype(res.numpy_dtype[0]), attrs)
            return
        bd = band_dims[0]
        if bd not in dims:
            dims[bd] = int(var._band_dim_sizes[0])
            bd_values = var._band_dim_values_map.get(bd)
            coords[bd] = (
                np.asarray(bd_values)
                if bd_values is not None
                else np.arange(int(var._band_dim_sizes[0])),
                # The eager arm stamps the same generic CF attributes on this
                # axis through `_create_dimension`; writing nothing here left
                # the two spellings of one call disagreeing about whether the
                # streamed file's `time` axis declares itself a time axis.
                build_coordinate_attrs(bd),
            )
        var_specs[name] = ((bd, "y", "x"), np.dtype(res.numpy_dtype[0]), attrs)

    def _add_aux_var_spec(
        self, name, rg, dims, coords, var_specs, aux_data, aux_vars=()
    ) -> None:
        """Add one carried-through auxiliary variable's dims/coords/spec and cache its whole array.

        The dimension coordinates an auxiliary is indexed by are written
        alongside it -- under exactly the rule the eager arm applies in
        :meth:`_aux_dimension_coordinate_names`, so the two spellings of one
        call produce the same set of arrays. Three cases are skipped, and a
        netCDF dimension with no coordinate variable is what the skip leaves
        behind, which is all a carried `lat_bnds` needs:

        * a **spatial** axis, which describes the grid the source had rather
          than the one being written. A streamed `to_crs(3857)` used to write
          the source's `lat`/`lon` beside the result's own `x`/`y`, and
          :meth:`_compute_geotransform` reads a lon/lat pair in preference to
          the stored transform, so reopening that file reported a geotransform
          and a bbox in degrees while the CRS said metres.
        * a dimension with **no indexing variable**, which used to get
          ``np.arange(size)``. That invents an axis the source does not have
          and the eager arm does not write: a streamed `to_crs` of the CF
          bounds fixture gained a `bnds = [0, 1]` array, and of the GOES
          granule a `band`, `number_of_image_bounds` and
          `number_of_time_bounds`.
        * a dimension whose indexing variable is **itself one of the carried
          auxiliaries**. GDAL reports `time_bounds` as the indexing variable of
          `number_of_time_bounds`, so the file gained a second copy of that
          array under the dimension's name beside the real one.

        Args:
            name: The auxiliary variable to write, possibly group-qualified.
            rg: The source store's working group.
            dims: Dimension sizes being accumulated for the output file.
            coords: Coordinate arrays being accumulated, keyed by dimension.
            var_specs: Variable specifications being accumulated.
            aux_data: Whole arrays being cached for the write pass.
            aux_vars: Every auxiliary this fan-out is carrying, so an indexing
                variable that is one of them is not also written as a
                coordinate. Defaults to empty.

        Returns:
            None: ``dims`` / ``coords`` / ``var_specs`` / ``aux_data`` are
            mutated in place.
        """
        # As in `_add_spatial_var_spec`: the name may be group-qualified.
        src_md = open_mdarray(rg, name)
        if src_md is None:
            return
        aux_dim_names = tuple(d.GetName() for d in src_md.GetDimensions() or [])
        for d in src_md.GetDimensions() or []:
            dn = d.GetName()
            if dn not in dims:
                iv = d.GetIndexingVariable()
                dims[dn] = int(d.GetSize())
                if iv is not None and dn.rsplit("/", 1)[-1].lower() not in AXIS_NAMES:
                    iv_name = NetCDF._group_relative_name(rg, iv)
                    if iv_name not in aux_vars:
                        coords[dn] = (
                            self._md_array_to_numpy(iv),
                            NetCDF._aux_attrs_with_unit(iv),
                        )
        arr = np.ascontiguousarray(self._md_array_to_numpy(src_md))
        var_specs[name] = (
            aux_dim_names,
            arr.dtype,
            NetCDF._aux_attrs_with_unit(src_md),
        )
        aux_data[name] = arr

    @staticmethod
    def _aux_attrs_with_unit(md_arr) -> dict[str, Any]:
        """A carried array's attributes, with the `units` GDAL lifted out put back.

        GDAL moves `units` out of the attribute dictionary into the MDArray's own
        unit slot -- the same lift that hides `scale_factor` / `add_offset` from
        :func:`~pyramids.netcdf.utils._read_attributes`. The eager arm copies the
        slot across (:meth:`_add_md_array_to_group` calls ``SetUnit``), so
        without this the streamed file's carried `lat` / `lon` / `band_id` came
        back with no units at all while the eager one said `degrees_north` --
        the difference between an axis a CF reader can interpret and one it
        cannot.

        Args:
            md_arr: The source :class:`osgeo.gdal.MDArray`.

        Returns:
            dict: The array's attributes plus `units`, when it declares one and
            the attributes do not already carry it.
        """
        attrs = _read_attributes(md_arr)
        unit = md_arr.GetUnit()
        if unit and "units" not in attrs:
            attrs["units"] = unit
        return attrs

    @staticmethod
    def _axes_are_geographic(template) -> bool | None:
        """Whether the streamed result's axes are degrees, metres, or unknown.

        Mirrors :func:`~pyramids.netcdf.engines.variables._resolve_write_crs`,
        which the eager arm reaches through ``from_array``: ``True`` for a
        geographic CRS, ``False`` for a projected one, and ``None`` when the
        result has no CRS at all -- the third state, where only the axis role is
        written, because claiming degrees or metres would assert a georeference
        the data does not have.

        Args:
            template: The transformed :class:`~pyramids.dataset.Dataset` the
                streamed file's grid is taken from.

        Returns:
            bool | None: The ``is_geographic`` value for
            :func:`~pyramids.netcdf.cf.build_coordinate_attrs`.
        """
        spec = crs_spec(template.epsg, template.crs)
        result: bool | None = None
        if spec:
            try:
                result = sr_from_user_input(spec).IsGeographic() == 1
            except (RuntimeError, TypeError, ValueError):
                result = None
        return result

    def _stream_root_attrs(self, template) -> dict[str, Any]:
        """Build the streamed file's root attributes (Conventions + CRS + GeoTransform + nodata)."""
        root_attrs: dict[str, Any] = dict(self.global_attributes)
        root_attrs.setdefault("Conventions", "CF-1.8")
        try:
            crs_wkt = template.crs.to_wkt() if template.crs is not None else None
        except AttributeError:
            crs_wkt = None
        if crs_wkt:
            root_attrs["crs_wkt"] = crs_wkt
        if template.epsg is not None:
            root_attrs["epsg"] = int(template.epsg)
        root_attrs["GeoTransform"] = " ".join(str(v) for v in template.geotransform)
        ndv = scalar_no_data(template.no_data_value)
        if ndv is not None:
            root_attrs["nodata"] = ndv
        return root_attrs

    @staticmethod
    def _write_stream_variable(writer, name, var, res) -> None:
        """Write one spatial variable: a band-dim variable slab-by-slab, a 2-D variable whole.

        Read with `unpack=False`. This copies a variable from one store to another, and
        `_stream_variable_specs` writes the source's `scale_factor` / `add_offset` onto the
        destination, so what belongs in the slab is the stored counts the recipe describes.
        Handing it the physical values instead would apply the packing a second time on the
        next read.
        """
        if var._band_dim_names:
            for i in range(int(var._band_dim_sizes[0])):
                plane = np.asarray(res.read_array(band=i, unpack=False))
                if plane.ndim == 3 and plane.shape[0] == 1:
                    plane = plane[0]
                writer.write_slab(name, i, plane)
            return
        plane = np.asarray(res.read_array(unpack=False))
        if plane.ndim == 3 and plane.shape[0] == 1:
            plane = plane[0]
        writer.write_whole(name, plane)

    def _apply_to_all_variables(
        self, operation, op_kwargs, path=None, *, warn_demoted=True
    ):
        """Apply a spatial operation to every gridded variable in the container.

        Only variables carrying both spatial axes are cropped / reprojected.
        Non-spatial auxiliary variables (e.g. ERA5's ``expver`` / ``number``,
        which have no ``y`` / ``x`` axes) can't go through the raster op, so they
        are **carried through unchanged** into the result rather than crashing the
        fan-out.

        Args:
            operation: Name of the Dataset method to call (e.g. "crop").
            op_kwargs: Keyword arguments to pass to the method.

        Returns:
            NetCDF: New container with the operation applied to every gridded
            variable and the non-spatial auxiliary variables carried through.

        Raises:
            ValueError: If the container has no data variables, or none of them
                are spatial (have both ``y`` / ``x`` axes).
        """
        names = self.variable_names
        if not names:
            raise ValueError(
                "Cannot apply operation to an empty container (no data variables)."
            )

        # Resolve the root group once and thread it through the spatial-variable scan
        # and the aux-variable dimension probe below, instead of re-resolving per call.
        rg = self._working_group()
        spatial_vars = self._spatial_variable_names(rg)
        aux_vars = self._carryable_aux_names(rg, spatial_vars)
        if not spatial_vars:
            # The readable superset, not `names`: that is the list
            # `_spatial_variable_names` scanned, and printing the narrower
            # enumeration told a user their `lat_rho` / `h` were never
            # considered when in fact both were tested and rejected. On the
            # suite's staggered ROMS fixture that is four of the six names.
            raise ValueError(
                f"{operation}() needs at least one spatial (y, x) variable; none of "
                f"{self._readable_variable_names(rg)} have both spatial axes."
            )

        self._warn_demoted_variables(rg, aux_vars, operation, warn_demoted)

        if path is not None:
            return self._to_file_via_stream_or_eager(
                operation, op_kwargs, spatial_vars, aux_vars, path
            )

        return self._fan_out_eager(spatial_vars, aux_vars, operation, op_kwargs)

    @staticmethod
    def _storable_no_data(no_data_value: Any, array: Any) -> Any:
        """The sentinel to rebuild with, or `None` where the dtype cannot hold it.

        Args:
            no_data_value: The source's no-data -- a scalar or the per-band
                tuple GDAL reports.
            array: The array being rebuilt, for its dtype.

        Returns:
            Any: The scalar sentinel the rebuild should declare, or `None`
                when the source carries none that this dtype can hold.

        Note:
            `no_data_value` is a *tuple*, so an `isinstance(..., list)` test
            never fired and the full per-band tuple used to leak into
            `from_array` (ARC-29); `scalar_no_data` handles both spellings.

            A NaN sentinel cannot be stored in an integer band, and GDAL does
            not refuse it -- it writes 0, which is an ordinary value of every
            integer type. On the suite's `int16` COARDS fixture, whose file
            declares no no-data at all, a container-wide crop came back
            declaring 0 as its no-data while the same crop taken per variable
            did not, so masking the container's result blanked real cells.
            Carrying no sentinel is what the source says; fabricating one that
            collides with the data is the only outcome that is wrong.

        Examples:
            - A sentinel a float band can hold survives the rebuild:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf.netcdf import NetCDF
              >>> NetCDF._storable_no_data(-9999.0, np.zeros((2, 2), "float32"))
              -9999.0

              ```

            - The per-band tuple GDAL reports collapses to the one scalar a
              rebuilt variable can declare:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf.netcdf import NetCDF
              >>> NetCDF._storable_no_data((250.0, 250.0), np.zeros((2, 2, 2), "uint8"))
              250.0

              ```

            - A NaN an integer band cannot store is dropped, rather than
              written out as the 0 GDAL would put there instead:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf.netcdf import NetCDF
              >>> print(NetCDF._storable_no_data(float("nan"), np.zeros((2, 2), "int16")))
              None

              ```

            - The same NaN against a float band is a usable sentinel, so it
              stays:

              ```python
              >>> import math
              >>> import numpy as np
              >>> from pyramids.netcdf.netcdf import NetCDF
              >>> math.isnan(NetCDF._storable_no_data(float("nan"), np.zeros((2, 2))))
              True

              ```
        """
        scalar = scalar_no_data(no_data_value)
        if scalar is not None and np.issubdtype(np.asarray(array).dtype, np.integer):
            try:
                if np.isnan(float(scalar)):
                    scalar = None
            except (TypeError, ValueError):
                pass
        return scalar

    def _fan_out_eager(self, spatial_vars, aux_vars, operation, op_kwargs) -> NetCDF:
        """Build an in-memory container by applying ``operation`` to each spatial variable.

        Each variable is transformed, materialised, and dropped into a shared `NetCDF` container
        (the first variable creates it); non-spatial auxiliary variables are then carried through
        unchanged. This is the historical eager fan-out — the ``path=None`` default of
        :meth:`_apply_to_all_variables`.
        """
        result = None
        for var_name in spatial_vars:
            var = self._require_raster_variable(var_name)
            var_result = getattr(var, operation)(**op_kwargs)
            # to_crs returns a VRT — materialize before the source goes
            # out of scope. read_array also squeezes singleton-band 3-D
            # variables to 2-D, so re-expand when the variable carried a
            # band/time/level dim originally.
            #
            # `unpack=False`: the rebuild is a copy of the store, and
            # `_carry_variable_attrs` stamps the source's scale/offset back
            # onto it below, so the array written here has to be the counts
            # that recipe describes. Reading physical values and then
            # declaring the packing over them applies it twice.
            # `squeeze=True` keeps the classic flattened `(bands, rows, cols)` this rebuild's
            # `unflatten_band_axes` expects, rather than the dimension-preserving default a
            # variable now returns (#1241); the compensation below then stays the inverse of
            # GDAL's flatten exactly as before.
            var_arr = var_result.read_array(unpack=False, squeeze=True)
            if var_arr.ndim == 2 and var._band_dim_name is not None:
                var_arr = np.expand_dims(var_arr, axis=0)
            # For 4-D+ variables, GDAL classic raster flattened the
            # non-spatial axes into a single bands axis on read — undo
            # that so the rebuild can materialise every band-dim. The
            # cached `_band_dim_sizes` describes the storage order
            # (last non-spatial dim varies fastest, matching GDAL's
            # row-major flatten), so the reshape is the literal
            # inverse of that flatten.
            var_arr = unflatten_band_axes(
                var_arr, var._band_dim_names, var._band_dim_sizes
            )
            var_ndv_scalar = NetCDF._storable_no_data(var_result.no_data_value, var_arr)
            extra_dims = (
                [
                    (name, var._band_dim_values_map.get(name))
                    for name in var._band_dim_names
                ]
                if var._band_dim_names
                else None
            )

            if result is None:
                # First variable: build the container. `ExtraDimensions(dims=None)` falls back to
                # the default single "time" axis, so both the band-dim and no-band-dim cases share
                # this one call.
                # Deliberately not carrying the source's spatial names or its CF axis
                # attributes here: this arm reprojects, crops and resamples, so the
                # result's grid is not the source's. A carried `lat` / `lon` pair is read
                # by `_compute_geotransform` in preference to the stored transform, which
                # made a reprojected result report the source grid — and the streamed arm
                # cannot carry them at all, so the two arms agree by carrying neither
                # (tests/netcdf/structure/test_fan_out_arms_agree.py).
                result = NetCDF.from_array(
                    var_arr,
                    geo_ref=GeoReference(
                        geo=var_result.geotransform,
                        epsg=crs_spec(var_result.epsg, var_result.crs),
                    ),
                    no_data_value=var_ndv_scalar,
                    variable_name=var_name,
                    dims=ExtraDimensions(dims=extra_dims),
                )
                # `from_array` takes only CF *global* attributes, so the source
                # variable's own attributes (`long_name`, `standard_name`, ...)
                # have to be written onto the new array directly. Without this a
                # container-wide crop/to_crs/resample silently returned variables
                # stripped of the descriptions the identical single-variable call
                # preserves. Written in place rather than re-stamped through
                # `set_variable`, which would write the array a second time and
                # drop the `grid_mapping` that `from_array` just added.
                NetCDF._carry_variable_attrs(result, var_name, var)
            else:
                # Subsequent variables: drop into the existing container.
                ds = Dataset.from_array(
                    var_arr,
                    no_data_value=var_ndv_scalar,
                    geo_ref=GeoReference(
                        geo=var_result.geotransform,
                        epsg=crs_spec(var_result.epsg, var_result.crs),
                    ),
                )
                NetCDF._copy_band_dim_metadata(ds, var)
                # `_resolve_band_metadata` picks these up off the source dataset
                # when `set_variable` is called without an explicit `attrs`.
                ds._variable_attrs = dict(getattr(var, "_variable_attrs", {}) or {})
                # `result` is a private container built by this fan-out; nothing else
                # references its raster yet, so mutate it in place (copy=False) to avoid
                # an O(n^2) per-variable MEM copy on wide cubes (#143).
                result.set_variable(var_name, ds, copy=False)
                # `set_variable` carries `_variable_attrs` but not the packing
                # slots, which GDAL keeps outside the attribute dictionary.
                NetCDF._carry_variable_attrs(result, var_name, var)

        self._carry_aux_variables(cast("NetCDF", result), aux_vars, operation)
        self._carry_aux_dimension_coordinates(
            cast("NetCDF", result), aux_vars, operation
        )
        return cast("NetCDF", result)

    @staticmethod
    def _carry_variable_attrs(container: NetCDF, var_name: str, source: Any) -> None:
        """Copy a source variable's own CF attributes and packing onto a rebuild.

        The rebuild routes go through `from_array`, which accepts CF *global*
        attributes only, so per-variable attributes such as `long_name` and
        `standard_name` are otherwise lost. Writing them onto the created
        MDArray is the same channel `set_variable` uses, without the second
        array write that re-stamping would cost.

        The packing -- `scale_factor` / `add_offset` -- is carried the same
        way, through `SetScale` / `SetOffset` rather than as attributes. GDAL
        lifts those two out of the attribute dictionary into the MDArray's own
        scale and offset slots, so they are absent from `_variable_attrs` and
        the attribute write above cannot restore them. Losing them silently
        corrupted a container-wide operation on a packed variable by the
        packing factor: the fan-out writes the array `read_array()` returns,
        so the rebuilt variable held packed counts with nothing left to say
        they were packed. On the suite's own `scale_factor=0.01` fixture,
        `nc.crop(mask).get_variable("z").read_array()` came back a
        hundredfold off from `nc.get_variable("z").crop(mask)` -- the same
        request, spelled the other way round.

        Restoring the slots is safe only while the stored array stays raw,
        which is why both rebuild arms read with an explicit `unpack=False`
        rather than relying on the read default. Unpacking became the default
        in #1124, and a rebuild that wrote physical values under a restored
        recipe would scale them a second time.

        Args:
            container: The freshly built container holding `var_name`.
            var_name: Name of the variable to stamp.
            source: The variable the attributes and packing are copied from.
        """
        attrs = dict(getattr(source, "_variable_attrs", {}) or {})
        scale = getattr(source, "_scale", None)
        offset = getattr(source, "_offset", None)
        rg = (
            container._working_group()
            if attrs or scale is not None or offset is not None
            else None
        )
        md_arr = open_mdarray(rg, var_name) if rg is not None else None
        if md_arr is not None:
            if attrs:
                write_attributes_to_md_array(md_arr, attrs)
            # A driver that does not carry packing answers `RuntimeError`
            # rather than storing it. That is not a reason to fail the
            # operation -- the array itself is intact either way -- so the
            # loss is left to the same read path that would have found no
            # packing on the source.
            try:
                if scale is not None:
                    md_arr.SetScale(scale)
                if offset is not None:
                    md_arr.SetOffset(offset)
            except (RuntimeError, AttributeError):
                pass

    @staticmethod
    def _declares_numeric(rg: Any, var_name: str) -> bool:
        """Whether `var_name` declares a numeric dtype, read off its declaration.

        Args:
            rg: The group to open the array from.
            var_name: The variable to check.

        Returns:
            bool: `True` for a numeric array, and for a name that cannot be
            opened, which is left to the caller's own handling.
        """
        md_arr = open_mdarray(rg, var_name)
        return md_arr is None or md_arr.GetDataType().GetClass() == gdal.GEDTC_NUMERIC

    def _warn_demoted_variables(self, rg, aux_vars, operation, warn_demoted) -> None:
        """Warn about auxiliary variables carried through untransformed for lack of spatial axes.

        A variable with >= 2 *unrecognised* axes is likely a grid whose axes were not recognised (no
        CF axis attributes / no known x/y names). Axes that are clearly non-spatial (time / vertical
        / ensemble / bounds) don't count, so a legitimately non-spatial N-D aux variable (e.g.
        ``(time, level)``) does not trip the warning. Nor does a string or compound array: its axes
        may well be `y` / `x`, and telling the user to rename them would be wrong -- it is not a
        grid because of its dtype, not its axes. ``warn_demoted=False`` suppresses the message
        on the eager fallback recursion, which already warned on the outer call.
        """
        demoted = [
            n
            for n in aux_vars
            if len(
                [
                    d
                    for d in self._variable_dim_names(rg, n)
                    if d.lower() not in _NONSPATIAL_AXIS_NAMES
                ]
            )
            >= 2
            and self._declares_numeric(rg, n)
        ]
        if demoted and warn_demoted:
            warnings.warn(
                f"{operation}() is carrying {len(demoted)} multi-dimensional "
                f"variable(s) {demoted} through unchanged because their axes were "
                f"not recognised as spatial (no CF axis attributes or known x/y "
                f"names); they will NOT be cropped/reprojected. Add CF axis "
                f"metadata (standard_name / axis) or rename the axes to y/x.",
                stacklevel=4,
            )

    def _to_file_via_stream_or_eager(
        self, operation, op_kwargs, spatial_vars, aux_vars, path
    ) -> NetCDF:
        """Write the transformed container to ``path``, streaming when possible else eager.

        The streaming path (ARC-46/#976) writes the cube one leading-dimension slab at a time, so the
        whole result is never resident. A shape the slab writer cannot represent makes
        ``_stream_apply_to_file`` return None, and we fall back to the eager in-memory build followed
        by a plain file write (still correct, just not bounded-memory for that rare shape). The
        fallback recursion passes ``warn_demoted=False`` so the demoted-variable warning — already
        emitted by the caller — is not duplicated, while genuine transform warnings still surface.
        """
        streamed = self._stream_apply_to_file(
            operation, op_kwargs, spatial_vars, aux_vars, path
        )
        if streamed is not None:
            return streamed
        mem = self._apply_to_all_variables(operation, op_kwargs, warn_demoted=False)
        return cast("NetCDF", mem._persist_to(path))

    def reduce(
        self,
        dim: str,
        how: str = "mean",
        *,
        groupby: list | tuple | str | None = None,
        skipna: bool = True,
        q: float | None = None,
    ) -> NetCDF:
        """Facade — :meth:`Selection.reduce <pyramids.netcdf.engines.selection.Selection.reduce>`."""
        return self.selection.reduce(dim, how, groupby=groupby, skipna=skipna, q=q)

    def coarsen(
        self,
        dim: str,
        window: int,
        *,
        how: str = "mean",
        boundary: str = "exact",
        skipna: bool = True,
        q: float | None = None,
    ) -> NetCDF:
        """Facade — :meth:`Selection.coarsen <pyramids.netcdf.engines.selection.Selection.coarsen>`."""
        return self.selection.coarsen(
            dim, window, how=how, boundary=boundary, skipna=skipna, q=q
        )

    def groupby_bins(
        self,
        dim: str,
        bins: int | Sequence[float],
        how: str = "mean",
        *,
        right: bool = True,
        include_lowest: bool = False,
        skipna: bool = True,
        q: float | None = None,
    ) -> NetCDF:
        """Facade — :meth:`Selection.groupby_bins <pyramids.netcdf.engines.selection.Selection.groupby_bins>`."""
        return self.selection.groupby_bins(
            dim,
            bins,
            how,
            right=right,
            include_lowest=include_lowest,
            skipna=skipna,
            q=q,
        )

    def rolling(
        self,
        dim: str,
        window: int,
        *,
        how: str = "mean",
        center: bool = False,
        min_periods: int | None = None,
        q: float | None = None,
    ) -> NetCDF:
        """Facade — :meth:`Selection.rolling <pyramids.netcdf.engines.selection.Selection.rolling>`."""
        return self.selection.rolling(
            dim, window, how=how, center=center, min_periods=min_periods, q=q
        )

    def diff(self, dim: str, n: int = 1, *, label: str = "upper") -> NetCDF:
        """Facade — :meth:`Selection.diff <pyramids.netcdf.engines.selection.Selection.diff>`."""
        return self.selection.diff(dim, n, label=label)

    def rank(self, dim: str, *, pct: bool = False) -> NetCDF:
        """Facade — :meth:`Selection.rank <pyramids.netcdf.engines.selection.Selection.rank>`."""
        return self.selection.rank(dim, pct=pct)

    def pad(
        self, *, mode: str = "constant", constant_values: Any = None, **pad_width: Any
    ) -> NetCDF:
        """Facade — :meth:`Selection.pad <pyramids.netcdf.engines.selection.Selection.pad>`."""
        return self.selection.pad(
            mode=mode, constant_values=constant_values, **pad_width
        )

    def transpose(self, *dims: Any) -> NetCDF:
        """Facade — :meth:`Selection.transpose <pyramids.netcdf.engines.selection.Selection.transpose>`."""
        return self.selection.transpose(*dims)

    def interp(self, method: str = "linear", **coords: Any) -> NetCDF:
        """Facade — :meth:`Selection.interp <pyramids.netcdf.engines.selection.Selection.interp>`."""
        return self.selection.interp(method, **coords)

    def interp_like(self, other: NetCDF, method: str = "linear") -> NetCDF:
        """Facade — :meth:`Selection.interp_like <pyramids.netcdf.engines.selection.Selection.interp_like>`."""
        return self.selection.interp_like(other, method=method)

    def convert_calendar(
        self, calendar: str, *, dim: str = "time", align_on: str = "date"
    ) -> NetCDF:
        """Facade — :meth:`Selection.convert_calendar
        <pyramids.netcdf.engines.selection.Selection.convert_calendar>`."""
        return self.selection.convert_calendar(calendar, dim=dim, align_on=align_on)

    def interp_calendar(self, target: NetCDF, *, dim: str = "time") -> NetCDF:
        """Facade — :meth:`Selection.interp_calendar
        <pyramids.netcdf.engines.selection.Selection.interp_calendar>`."""
        return self.selection.interp_calendar(target, dim=dim)

    def differentiate(self, dim: str) -> NetCDF:
        """Facade — :meth:`Selection.differentiate <pyramids.netcdf.engines.selection.Selection.differentiate>`."""
        return self.selection.differentiate(dim)

    def integrate(self, dim: str) -> NetCDF:
        """Facade — :meth:`Selection.integrate <pyramids.netcdf.engines.selection.Selection.integrate>`."""
        return self.selection.integrate(dim)

    def cumulative_integrate(self, dim: str) -> NetCDF:
        """Facade — :meth:`Selection.cumulative_integrate
        <pyramids.netcdf.engines.selection.Selection.cumulative_integrate>`."""
        return self.selection.cumulative_integrate(dim)

    def polyfit(self, dim: str, deg: int) -> NetCDF:
        """Facade — :meth:`Selection.polyfit <pyramids.netcdf.engines.selection.Selection.polyfit>`."""
        return self.selection.polyfit(dim, deg)

    def curvefit(
        self,
        dim: str,
        func: Callable[..., Any],
        p0: Sequence[float],
        *,
        bounds: tuple[Any, Any] | None = None,
        full: bool = False,
    ) -> NetCDF:
        """Facade — :meth:`Selection.curvefit <pyramids.netcdf.engines.selection.Selection.curvefit>`."""
        return self.selection.curvefit(dim, func, p0, bounds=bounds, full=full)

    def rolling_exp(self, dim: str, alpha: float, *, how: str = "mean") -> NetCDF:
        """Facade — :meth:`Selection.rolling_exp <pyramids.netcdf.engines.selection.Selection.rolling_exp>`."""
        return self.selection.rolling_exp(dim, alpha, how=how)

    def cumulative(self, dim: str) -> CumulativeAccessor:
        """Facade — :meth:`Selection.cumulative <pyramids.netcdf.engines.selection.Selection.cumulative>`."""
        return self.selection.cumulative(dim)

    def cumsum(self, dim: str, *, skipna: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.cumsum <pyramids.netcdf.engines.selection.Selection.cumsum>`."""
        return self.selection.cumsum(dim, skipna=skipna)

    def cumprod(self, dim: str, *, skipna: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.cumprod <pyramids.netcdf.engines.selection.Selection.cumprod>`."""
        return self.selection.cumprod(dim, skipna=skipna)

    def shift(self, dim: str, periods: int = 1, *, fill_value: Any = None) -> NetCDF:
        """Facade — :meth:`Selection.shift <pyramids.netcdf.engines.selection.Selection.shift>`."""
        return self.selection.shift(dim, periods, fill_value=fill_value)

    def ffill(self, dim: str, *, limit: int | None = None) -> NetCDF:
        """Facade — :meth:`Selection.ffill <pyramids.netcdf.engines.selection.Selection.ffill>`."""
        return self.selection.ffill(dim, limit=limit)

    def bfill(self, dim: str, *, limit: int | None = None) -> NetCDF:
        """Facade — :meth:`Selection.bfill <pyramids.netcdf.engines.selection.Selection.bfill>`."""
        return self.selection.bfill(dim, limit=limit)

    def dropna(
        self, dim: str, *, how: str = "any", thresh: int | None = None
    ) -> NetCDF:
        """Facade — :meth:`Selection.dropna <pyramids.netcdf.engines.selection.Selection.dropna>`."""
        return self.selection.dropna(dim, how=how, thresh=thresh)

    def interpolate_na(
        self,
        dim: str,
        method: str = "linear",
        *,
        limit: int | None = None,
        use_coordinate: bool = True,
    ) -> NetCDF:
        """Facade — :meth:`Selection.interpolate_na <pyramids.netcdf.engines.selection.Selection.interpolate_na>`."""
        return self.selection.interpolate_na(
            dim, method, limit=limit, use_coordinate=use_coordinate
        )

    def to_dataframe(  # type: ignore[override]
        self, *, variables: Any = None, dropna: bool = False
    ):
        """Facade — :meth:`Interop.to_dataframe <pyramids.netcdf.engines.interop.Interop.to_dataframe>`.

        A cube tabulates by variable/`dropna`, deliberately unlike the raster
        `Dataset.to_dataframe(*, bands=…)` it overrides — hence `type: ignore[override]`,
        matching `read_array` and the other intentionally-divergent NetCDF facades.
        """
        return self.interop.to_dataframe(variables=variables, dropna=dropna)

    def to_dict(self, *, data: bool = True) -> dict[str, Any]:
        """Facade — :func:`cube_to_dict <pyramids.netcdf.dict_io.cube_to_dict>`."""
        return _dict_io.cube_to_dict(self, data=data)

    @classmethod
    def from_dict(cls, payload: Any) -> Container:
        """Facade — :func:`cube_from_dict <pyramids.netcdf.dict_io.cube_from_dict>`.

        A `classmethod` that does not use `cls`, exactly as :meth:`from_array`,
        :meth:`from_bytes` and :meth:`from_xarray` are: each always builds a `Container`
        whatever subtype it is invoked on, and `from_array`'s docstring records that choice.
        """
        return _dict_io.cube_from_dict(payload)

    def to_dask_dataframe(
        self, *, variables: Any = None, dropna: bool = False, chunks: Any = "auto"
    ):
        """Facade — :meth:`Interop.to_dask_dataframe <pyramids.netcdf.engines.interop.Interop.to_dask_dataframe>`.

        The lazy sibling of :meth:`to_dataframe`: a tidy :class:`dask.dataframe.DataFrame`
        (dimension coordinates as columns beside the value columns) read lazily through
        :meth:`read_array`. `to_dask_dataframe(...).compute().set_index([*dims])` reproduces
        `to_dataframe()`.
        """
        return self.interop.to_dask_dataframe(
            variables=variables, dropna=dropna, chunks=chunks
        )

    def chunk(self, chunks: Any = "auto") -> LazyNetCDF:
        """Return a lazy, dask-backed view of this cube (issue #1229).

        Every gridded variable is read lazily through :meth:`read_array` with `chunks` and wrapped
        in a :class:`~pyramids.netcdf._lazy_cube.LazyNetCDF` — the raster-cube analogue of
        :class:`~pyramids.feature.LazyFeatureCollection`. The view exposes `chunks` / `chunksizes`,
        materialises back to this eager cube with `compute()` / `load()`, and manages the dask graph
        with `persist()` / `unify_chunks()`.

        Only a **file-backed** cube can be chunked — the lazy read reopens the store per block, so an
        in-memory or in-place-rebuilt cube (no on-disk path) is refused by :meth:`read_array` with a
        clear error.

        Args:
            chunks: Chunking spec forwarded to :meth:`read_array`; `"auto"` (default) targets dask's
                byte budget. Any form `read_array` accepts works.

        Returns:
            LazyNetCDF: A lazy view over this cube's gridded variables.

        Raises:
            ValueError: This cube has no on-disk store to read lazily (see :meth:`read_array`).
            OptionalPackageDoesNotExist: dask is not installed — install the `lazy` extra.
        """
        if _interop._is_in_memory(self):
            raise ValueError(
                "chunk() needs a file-backed cube: the lazy read reopens the store per block, and "
                "this cube has no on-disk path. Write it with to_file() first, or operate eagerly."
            )
        pinned = self._source_var_name
        if pinned is None:
            names = list(self._spatial_variable_names())
        else:
            names = [pinned]
        arrays: dict[str, Any] = {}
        dim_names: dict[str, tuple[str, ...]] = {}
        for name in names:
            var = self if pinned is not None else self._require_raster_variable(name)
            array = (
                self.read_array(chunks=chunks, squeeze=False)
                if pinned is not None
                else self.read_array(name, chunks=chunks, squeeze=False)
            )
            arrays[name] = array
            dim_names[name] = _lazy_cube_dim_names(var, array.ndim)
        return LazyNetCDF(self, arrays, dim_names)

    @_joins_cubes
    def concat(self, objs: Any, dim: str) -> NetCDF:
        """Join cubes end to end along one of their dimensions.

        Two halves of a time series becoming the whole. Every cube must be on the same
        grid, carry the same variables, and agree on every dimension but `dim`; the joined
        dimension's coordinates are laid end to end in the order the cubes were given.

        See :meth:`merge` for the other join — several variables on one grid, rather than
        one set of variables over a longer axis.

        Every cube's gaps stay gaps: the result declares the first cube's no-data value
        and the other cubes' gaps are rewritten to it. The one cell this cannot get right
        is a later cube holding the first cube's sentinel as a real measurement, which is
        then read as missing — change one of the sentinels before joining such cubes.

        Callable either way: `NetCDF.concat([first, second], dim)` joins the list, and
        `first.concat([second], dim)` joins the receiver ahead of it. The two answer the
        same cube.

        Args:
            objs: The cubes, containers or variables, in the order they are joined. On an
                instance call the receiver comes first, ahead of these.
            dim: The non-spatial dimension to join along.

        Returns:
            NetCDF: One container holding the joined cubes.

        Raises:
            ValueError: `objs` is empty, the cubes carry different variables, or a variable
                lacks `dim` or disagrees on another dimension.
            AlignmentError: The cubes are not on the same grid.

        Examples:
            - Two halves of a four-step cube, joined back into it:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
              >>> first = NetCDF.from_array(
              ...     np.array([1.0, 2.0]).reshape(2, 1, 1), geo_ref=geo_ref,
              ...     variable_name="t", dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
              ... )
              >>> second = NetCDF.from_array(
              ...     np.array([3.0, 4.0]).reshape(2, 1, 1), geo_ref=geo_ref,
              ...     variable_name="t", dims=ExtraDimensions(name="time", values=[12.0, 18.0]),
              ... )
              >>> whole = NetCDF.concat([first, second], "time")
              >>> whole.get_variable("t").read_array().ravel().tolist()
              [1.0, 2.0, 3.0, 4.0]
              >>> whole.get_variable("t")._band_dim_values_map["time"]
              [0.0, 6.0, 12.0, 18.0]

              ```
            - The same join reached through the first cube, which goes at the front:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
              >>> first = NetCDF.from_array(
              ...     np.array([1.0, 2.0]).reshape(2, 1, 1), geo_ref=geo_ref,
              ...     variable_name="t", dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
              ... )
              >>> second = NetCDF.from_array(
              ...     np.array([3.0, 4.0]).reshape(2, 1, 1), geo_ref=geo_ref,
              ...     variable_name="t", dims=ExtraDimensions(name="time", values=[12.0, 18.0]),
              ... )
              >>> first.concat([second], "time").get_variable("t").read_array().ravel().tolist()
              [1.0, 2.0, 3.0, 4.0]

              ```
        """
        return _concat(_with_receiver(self, objs), dim)

    @_joins_cubes
    def merge(self, objs: Any, *, compat: str = "no_conflicts") -> NetCDF:
        """Put the variables of several cubes side by side on the grid they share.

        **Not** `DatasetCollection.merge`, which means a *spatial mosaic written to a
        destination file* — several rasters covering neighbouring ground becoming one. This
        is the other operation: one grid, several variables. See :meth:`concat` for joining
        along a dimension instead.

        Callable either way: `NetCDF.merge([first, second])` merges the list, and
        `first.merge([second])` merges the receiver ahead of it.

        No dimension is joined here, so the copies of a variable have to agree about every
        one of them, coordinates included — two measurements a hundred hours apart are
        refused rather than fused into one step carrying the first's stamp. Use
        :meth:`concat` to put them end to end instead.

        Args:
            objs: The cubes, containers or variables, all on the same grid. On an
                instance call the receiver comes first, ahead of these.
            compat: What to do with a variable more than one cube carries.
                `"no_conflicts"` (default) is xarray's rule: the copies fill each other's
                gaps, and only a cell both of them hold a *different* value in is a
                conflict. A value borrowed into a band that cannot hold it widens that
                band rather than being truncated into it. `"override"` takes the first
                cube's copy as it stands, gaps and all, without reading any other.

        Returns:
            NetCDF: One container holding the union of the variables.

        Raises:
            ValueError: `objs` is empty, `compat` is unknown, two copies of a variable are
                stamped differently along a band dimension, or two cubes disagree about a
                cell they both judged.
            AlignmentError: The cubes are not on the same grid.

        Examples:
            - Two single-variable cubes becoming one container of both:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import GeoReference, NetCDF
              >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
              >>> rain = NetCDF.from_array(
              ...     np.ones((1, 1)), geo_ref=geo_ref, variable_name="rain"
              ... )
              >>> temp = NetCDF.from_array(
              ...     np.zeros((1, 1)), geo_ref=geo_ref, variable_name="temp"
              ... )
              >>> sorted(NetCDF.merge([rain, temp]).variable_names)
              ['rain', 'temp']

              ```
            - The same merge reached through the first cube:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import GeoReference, NetCDF
              >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
              >>> rain = NetCDF.from_array(
              ...     np.ones((1, 1)), geo_ref=geo_ref, variable_name="rain"
              ... )
              >>> temp = NetCDF.from_array(
              ...     np.zeros((1, 1)), geo_ref=geo_ref, variable_name="temp"
              ... )
              >>> sorted(rain.merge([temp]).variable_names)
              ['rain', 'temp']

              ```
        """
        return _merge(_with_receiver(self, objs), compat=compat)

    def weighted(
        self,
        weights: Any,
        dims: Any = None,
        *,
        how: str = "mean",
        skipna: bool = True,
        q: float | None = None,
    ) -> NetCDF:
        """Facade — :meth:`Selection.weighted <pyramids.netcdf.engines.selection.Selection.weighted>`."""
        return self.selection.weighted(weights, dims, how=how, skipna=skipna, q=q)

    def argmin(self, dim: str, *, skipna: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.argmin <pyramids.netcdf.engines.selection.Selection.argmin>`."""
        return self.selection.argmin(dim, skipna=skipna)

    def argmax(self, dim: str, *, skipna: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.argmax <pyramids.netcdf.engines.selection.Selection.argmax>`."""
        return self.selection.argmax(dim, skipna=skipna)

    def idxmin(self, dim: str, *, skipna: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.idxmin <pyramids.netcdf.engines.selection.Selection.idxmin>`."""
        return self.selection.idxmin(dim, skipna=skipna)

    def idxmax(self, dim: str, *, skipna: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.idxmax <pyramids.netcdf.engines.selection.Selection.idxmax>`."""
        return self.selection.idxmax(dim, skipna=skipna)

    @staticmethod
    def _is_file_backed(var: NetCDF) -> bool:
        """True when the variable's data lives in a reopenable file, so a lazy chunk read is possible.

        An in-memory container (e.g. built by `from_array`) has no file for the lazy chunk
        graph to reopen; streaming it saves nothing and the reopen would fail.

        Args:
            var: The variable subset to probe.

        Returns:
            True when the source path exists on disk or is a remote (`/vsi*` / cloud) URL.
        """
        parent = var._parent_nc if var._parent_nc is not None else var
        # Prefer the real /vsimem backing path over a cosmetic from_bytes `name=` that shadows
        # _file_name, so a *named* in-memory read is recognised as reopenable (streamable) too,
        # matching the unnamed case and the lazy read fix (#1059; shadow family #1050/#1053/#1057/
        # #1058). A pure MEM container (from_array) has neither, so it stays not-file-backed.
        path = getattr(parent, "_vsimem_path", None) or parent._file_name
        if not path:
            return False
        path = strip_netcdf_subdataset_prefix(path)
        return os.path.isfile(path) or is_remote(path)

    @staticmethod
    def _materialize_variable_array(var: NetCDF, lazy: bool = False) -> Any:
        """Read a variable as `(*band_dim_sizes, rows, cols)` (or `(rows, cols)`).

        With `lazy=True`, dask (the `[lazy]` extra) installed, and a file-backed variable that still
        reads as its store, returns a chunked `dask.array` in that same layout without ever holding
        the whole variable in RAM: every operation along a band dimension runs its numpy calls over
        the chunks rather than over one resident array (ARC-47). What that saves depends on the
        member. `reduce`, `coarsen`, the extremum locators and `weighted` collapse the dimension, so
        only the (small) result is ever held. `rolling`, `cumsum` and `shift` answer a result the
        size of the input, and `diff` one step shorter, so the chunks are still read a chunk at a
        time but the answer itself is as large as the variable. The chunked read already keeps each
        non-spatial dim as its own leading axis, so no reshape is needed. Otherwise it reads eagerly
        with `read_array(squeeze=False)`, which returns that same dimension-preserving layout
        (undoing GDAL's row-major band flatten and `read_array`'s singleton squeeze).

        Because the streamed reduce tree-reduces per chunk via dask while the eager path reduces in a
        single pass, a file-backed `mean`/`sum`/`std`/`var` can differ from the same in-memory reduce
        in the last ULPs (floating-point non-associativity) — equal to `np.allclose`, not bit-for-bit.

        Only a variable that still reads as its store is streamed (`_reads_as_its_store`): the
        chunked read rebuilds the store's variable from the parent file, so anything derived from it
        — a `sel` / `isel` cut, a crop, `to_crs`, `resample`, `warped_view`, an operator result, an
        in-place `apply` / `fill` / `change_no_data_value` — is read eagerly from its own raster
        instead.

        Args:
            var: The variable to read.
            lazy: Whether a streamed read may be returned.

        Returns:
            numpy.ndarray | dask.array.Array: The values, laid out `(*band_dim_sizes, rows, cols)`
            for a variable with band dimensions.
        """
        if lazy and NetCDF._is_file_backed(var) and NetCDF._reads_as_its_store(var):
            # Only stream a genuinely file-backed variable: an in-memory container has no file for the
            # chunk graph to reopen and is already fully in RAM, so streaming saves nothing. Checking
            # file-backing up front (rather than a broad except) lets a real file-backed read error
            # propagate instead of silently degrading to a whole-cube eager read / OOM (review M1).
            try:
                return var.read_array(chunks="auto")
            except ImportError:
                pass  # dask (the [lazy] extra) not installed -> eager fallback below
        arr = var.read_array(squeeze=False)
        return cast("np.typing.NDArray", arr)

    @staticmethod
    def _reads_as_its_store(var: NetCDF) -> bool:
        """Whether `var` still holds the raster `get_variable` built for it from its store.

        The chunked read that streams a reduction rebuilds a variable from its parent file and its
        name, so it describes `var` only while `var` is that store variable, unchanged.
        `get_variable` records the raster it built (`_store_raster`), and a variable streams only
        while it still holds that very object:

        - every derivation returns a new object with no record — a `sel` / `isel` cut, `to_crs`,
          `warped_view`, `resample`, a crop, an operator result;
        - an in-place `apply`, `fill` or `change_no_data_value` swaps a new raster in through
          `_update_inplace`, and `_replace_raster` swaps one in directly; both drop the record
          (it becomes `None`). The `epsg` setter hands `_update_inplace` the same raster, so the
          record is kept and still matches;
        - `close()` drops the record too;
        - `_materialize_md_view` does carry it, because it copies the view's values unchanged, so
          a variable whose view a `resample`, `to_crs` or crop copied as a side effect keeps
          streaming. A geostationary variable is copied by that method while `get_variable`
          builds it, and streams because `get_variable` records the raster after that copy.

        The GDAL driver alone cannot tell these apart: a warped variable is a `VRT` over the
        store and must not stream, while a materialized whole variable is `MEM` and may.

        Args:
            var: The variable about to be read.

        Returns:
            bool: `True` when `var`'s raster is the one `get_variable` recorded.
        """
        store_raster = getattr(var, "_store_raster", None)
        return store_raster is not None and var._raster is store_raster

    def _resolve_group_positions(
        self, dim: str, groupby: list | tuple | str | None
    ) -> list[np.typing.NDArray] | None:
        """Resolve `groupby` into ordered lists of source index positions.

        A frequency groups the steps of `dim` by calendar window, skipping empty windows, over the
        stamps `_group_stamps` decodes. They are decoded through `get_time_variable` when this
        object holds no coordinates of its own for `dim`, and through `_decode_time_labels` when
        it does, which finds the units its store declares or those a derived result carries. A
        container whose stored axis has no units of its own — one rebuilt by any operation along
        a band dimension, `reduce` and `coarsen` included — falls back to decoding its
        dimension's values with the units it carries. A sequence of labels groups equal labels,
        in first-appearance order.

        Args:
            dim: The band dimension being grouped.
            groupby: `None` to collapse `dim`, a pandas offset alias such as `"1D"`, or one label
                per step of `dim`.

        Returns:
            list[numpy.ndarray] | None: One ascending array of positions per group, or `None`
            when `groupby` is `None`.

        Raises:
            ValueError: `groupby` is a frequency and no time coordinate of `dim` decodes.

        Examples:
            - Labels group their steps in first-appearance order:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> var = NetCDF.from_array(
              ...     np.arange(4.0).reshape(4, 1, 1),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0, 24.0, 30.0]),
              ... ).get_variable("t")
              >>> [p.tolist() for p in var._resolve_group_positions("time", ["b", "a", "b", "a"])]
              [[0, 2], [1, 3]]

              ```
            - A frequency needs stamps with time units, which these raw hours lack:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> var = NetCDF.from_array(
              ...     np.arange(2.0).reshape(2, 1, 1),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
              ... ).get_variable("t")
              >>> var._resolve_group_positions("time", "1D")
              Traceback (most recent call last):
                ...
              ValueError: Cannot group dimension 'time' by frequency '1D': no decodable time coordinate found.

              ```
        """
        positions: list[np.ndarray] | None = None
        if isinstance(groupby, str):
            times = self._group_stamps(dim)
            if times is None:
                raise ValueError(
                    f"Cannot group dimension {dim!r} by frequency {groupby!r}: "
                    f"no decodable time coordinate found."
                )
            index = pd.DatetimeIndex(pd.to_datetime(times))
            series = pd.Series(np.arange(len(index)), index=index)
            positions = [
                np.sort(members.to_numpy())
                for _, members in series.groupby(pd.Grouper(freq=groupby))
                if len(members) > 0
            ]
        elif groupby is not None:
            labels = list(groupby)
            order: list[Any] = []
            members: dict[Any, list[int]] = {}
            for i, label in enumerate(labels):
                if label not in members:
                    members[label] = []
                    order.append(label)
                members[label].append(i)
            positions = [np.array(members[label]) for label in order]
        return positions

    def _group_stamps(self, dim: str) -> list[str] | None:
        """The decoded stamps of `dim`, to the second, that a frequency groups by.

        Full-resolution timestamps: the default `"%Y-%m-%d"` truncates to whole days, which would
        collapse every sub-daily frequency (`"1H"`, `"3H"`, `"6H"`) into a single per-day bucket.

        - Coordinates of its own for `dim` (a variable keeps its own, possibly selected, raw
          stamps but not the root group's units) are decoded with the units its store declares,
          or that a derived result carries (`_time_attr_candidates`).
        - A container decodes its stored axis the same way: with the units its own metadata
          declares, or, for one rebuilt by an operation along a band dimension (`reduce`,
          `coarsen`, `rolling`, ...) that stores its axis without units, with the units it
          carries in `_band_dim_time_attrs`.
        - A variable without coordinates of its own falls back to `get_time_variable`, which
          reads its own metadata.

        Every branch but the last decodes leniently, so a stamp that does not convert (a NaN, an
        infinity) gives `None`, as it does for a variable, rather than raising out of the
        conversion.

        Args:
            dim: The band dimension.

        Returns:
            list[str] | None: One `"%Y-%m-%d %H:%M:%S"` stamp per step of `dim`, or `None` when
            no time coordinate of `dim` decodes.
        """
        instant = "%Y-%m-%d %H:%M:%S"
        own_values = self._band_dim_values_map.get(dim)
        if own_values is not None:
            times = self._decode_time_labels(
                dim, list(own_values), instant, strict=False
            )
        elif not self._band_dim_names:
            stored = self.get_dimension_values(dim)
            times = (
                None
                if stored is None
                else self._decode_time_labels(dim, list(stored), instant, strict=False)
            )
        else:
            times = self.get_time_variable(var_name=dim, time_format=instant)
        return times

    @staticmethod
    def _carried_axis_metadata(
        source: NetCDF | None,
    ) -> tuple[tuple[str, str] | None, dict[str, dict[str, str]] | None]:
        """What a rebuilt store should say about its axes, taken from the source.

        Two things a rebuild used to invent rather than carry:

        - the **spatial names**. `from_array` names its grid `y` / `x`, so a source on
          `latitude` / `longitude` came back renamed, in memory and on the written file
          (#1180). `interop._public_spatial_names` recovers the store's own names
          positionally, and falls back to `y` / `x` for a variable with no parent — so an
          in-memory build is unchanged.
        - the **CF time attributes**. The result carries `(units, calendar)` on the Python
          object, and the store it is written into never had them, so `to_file` had
          nothing to copy and the calendar died at the write (#1179).

        Args:
            source: The variable the result is derived from, or `None` when there is none.

        Returns:
            tuple: `(spatial_names, dim_attrs)`, either of which is `None` when the source
            says nothing about it.
        """
        names = None
        attrs = None
        if source is not None:
            try:
                names = _interop._public_spatial_names(source)
            except AttributeError:
                # The documented case: an object that never grew the `_md_array_dims` the
                # resolution reads. Nothing wider is caught, because a `TypeError` or an
                # `IndexError` from that code is a defect in it, and swallowing one here
                # would rename the axes back to `y` / `x` — the #1180 symptom — silently.
                names = None
            attrs = NetCDF._cf_axis_attributes(source._resolved_band_dim_time_attrs())
        return names, attrs

    @staticmethod
    def _cf_axis_attributes(time_attrs: dict | None) -> dict | None:
        """The `(units, calendar)` pairs as the attributes `from_array` writes.

        Args:
            time_attrs: `(units, calendar)` per dimension, as
                `_resolved_band_dim_time_attrs` reports them. Either half may be
                empty, and a dimension declaring neither is left out.

        Returns:
            dict | None: `{dim: {"units": …, "calendar": …}}`, or `None` when no
            dimension declares anything — which is what `ExtraDimensions.attrs`
            takes to mean "write no CF attributes".
        """
        carried = {}
        for dim, (units, calendar) in (time_attrs or {}).items():
            written = {}
            if units:
                written["units"] = units
            if calendar:
                written["calendar"] = calendar
            if written:
                carried[dim] = written
        return carried or None

    def _stack_reduced_variable(
        self,
        result,
        var_name,
        arr,
        geo,
        epsg,
        ndv,
        band_names,
        values_map,
        source=None,
        time_attrs=None,
    ):
        """Add a reduced variable into the result container, building it lazily.

        Args:
            result: The container being built, or `None` for the first variable.
            var_name: The variable's name.
            arr: Its cells.
            geo: The geotransform.
            epsg: The CRS.
            ndv: The no-data value.
            band_names: The band dimension names.
            values_map: Their coordinate values.
            source: The variable this one is derived from, whose axis names and CF time
                attributes the rebuilt store should carry (#1179, #1180). `None` keeps the
                `y` / `x` naming and writes no CF attributes.
            time_attrs: The `(units, calendar)` per dimension to write, overriding the
                source's. `None` (default) writes the source's. `concat` and `merge` pass
                the consensus they computed across every part, which is empty for a
                dimension the parts disagree about — adopting one part's calendar for all
                of them is the misreading `_label_combined`'s policy and
                `test_disagreeing_units_are_dropped_rather_than_guessed` forbid.

        Returns:
            NetCDF: The container, built on the first call and added to afterwards.
        """
        extra = (
            [(name, values_map.get(name)) for name in band_names]
            if band_names
            else None
        )
        spatial_names, dim_attrs = NetCDF._carried_axis_metadata(source)
        # Names describe a grid, so they travel only while the grid is the source's. A
        # `weighted` collapses the spatial axes to one cell, and calling that single cell
        # `lat` leaves the store with two different lengths under one name — which is what
        # the rename to `y` / `x` was protecting against
        # (tests/netcdf/selection/test_weighted_edges.py).
        if spatial_names is not None and not NetCDF._same_grid(source, arr, geo):
            spatial_names = None
        if time_attrs is not None:
            dim_attrs = NetCDF._cf_axis_attributes(time_attrs)
        if dim_attrs:
            # A `reduce` collapses the very dimension the source declared units for, so
            # what is carried is trimmed to the dimensions the result kept. `from_array`
            # refuses attributes addressed to an axis that is not there, which is a
            # caller's typo and not this.
            dim_attrs = {
                dim: written for dim, written in dim_attrs.items() if dim in band_names
            } or None
        if result is None:
            result = NetCDF.from_array(
                arr,
                geo_ref=GeoReference(geo=geo, epsg=epsg),
                no_data_value=ndv,
                variable_name=var_name,
                dims=ExtraDimensions(dims=extra, attrs=dim_attrs),
                spatial_names=spatial_names,
            )
        else:
            # A Dataset stores at most one (flattened) band axis, so collapse the
            # trailing band dimensions to a single (prod(sizes), rows, cols) store.
            # _materialize_variable_array reshapes it back using _band_dim_sizes.
            flat = (
                arr.reshape(-1, arr.shape[-2], arr.shape[-1])
                if len(band_names) > 1
                else arr
            )
            ds = Dataset.from_array(
                flat,
                no_data_value=ndv,
                geo_ref=GeoReference(geo=geo, epsg=epsg),
            )
            ds._band_dim_names = tuple(band_names)
            ds._band_dim_values_map = {
                name: values_map.get(name) for name in band_names
            }
            ds._band_dim_sizes = tuple(arr.shape[i] for i in range(len(band_names)))
            ds._band_dim_name, ds._band_dim_values = NetCDF._derive_primary_band_view(
                ds._band_dim_names,
                ds._band_dim_values_map,
                ds._band_dim_sizes,
                ds._band_count,
            )
            # `result` is a private container this reduction builds up; nothing else
            # references its raster yet, so mutate in place (copy=False) to avoid an
            # O(n^2) per-variable MEM copy on wide cubes (#143). `dim_attrs` reaches the
            # band dimensions this variable owns and the first did not — a `merge` of a
            # time cube with a level cube creates `level` here, and without this its CF
            # units would live only in memory and die at `to_file` (#1179).
            result.set_variable(var_name, ds, copy=False, dim_attrs=dim_attrs)
        return result

    def to_crs(
        self,
        to_epsg: int,
        method: str = DEFAULT_RESAMPLING,
        maintain_alignment: bool = False,
        path: str | Path | None = None,
    ) -> NetCDF:
        """Reproject the dataset to a different CRS.

        On a **root MDIM container** this reprojects every variable
        and returns a new container. On a **variable subset** it
        delegates to `Dataset.to_crs()` and wraps the result as
        `NetCDF` to preserve variable metadata.

        Args:
            to_epsg: Target EPSG code (e.g., 4326, 32637).
            method: Resampling method. Defaults to `"nearest neighbor"`.
            maintain_alignment: If True, keep the same number of rows
                and columns. Defaults to False.
            path: Optional output `.nc` path. When set, the reprojected container is streamed
                straight to that file one slab at a time (so the whole cube is never resident) and a
                **file-backed** `NetCDF` reading it is returned; `None` (default) builds the result
                in memory.

        Returns:
            NetCDF: Reprojected container or variable subset.

        Notes:
            The result sits on a **new grid**, so its spatial axes are named `y` / `x`
            rather than the source's. A member that leaves the horizontal grid unchanged —
            `coarsen`, `reduce`, `rolling`, `cumsum`, `diff` over a *band* dimension —
            keeps the names with it; a *spatial* `coarsen` / `reduce` does change the grid
            and (correctly) drops them, the same as here. Compared by membership below
            because `dimension_names` lists the store's dimensions in its own order, not a
            promised `(row, column)` one:

            ```python
            >>> import numpy as np
            >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
            >>> cube = NetCDF.from_array(
            ...     np.arange(8.0).reshape(2, 2, 2),
            ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
            ...     variable_name="t",
            ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
            ...     spatial_names=("latitude", "longitude"),
            ... )
            >>> sorted(cube.get_variable("t").coarsen("time", 2).dimension_names)
            ['latitude', 'longitude', 'time']
            >>> sorted(cube.to_crs(3857).dimension_names)
            ['time', 'x', 'y']

            ```

            Carrying `latitude` / `longitude` onto a reprojected grid would name axes
            after coordinates they no longer hold, so the rename is deliberate. It is the
            same for `resample` and `crop`.
        """
        if self._is_root_container:
            result = self._apply_to_all_variables(
                "to_crs",
                {
                    "to_epsg": to_epsg,
                    "method": method,
                    "maintain_alignment": maintain_alignment,
                },
                path=path,
            )
        else:
            # to_crs warps the backing raster; a multidim view can't be window-read by GDAL >= 3.13,
            # so materialize it first (mirrors resample).
            self._materialize_md_view()
            result = super().to_crs(
                to_epsg=to_epsg,
                method=method,
                maintain_alignment=maintain_alignment,
            )
            result = self._preserve_netcdf_metadata(result)._persist_to(path)
        return cast("NetCDF", result)

    def warped_view(
        self,
        crs: int | str | Any,
        method: str = DEFAULT_RESAMPLING,
        *,
        cell_size: float | None = None,
        bbox: tuple[float, float, float, float] | None = None,
    ) -> NetCDF:
        """Return a lazy, reprojected view of a **variable subset**.

        Delegates to :meth:`pyramids.dataset.Dataset.warped_view` and re-wraps
        the VRT-backed result as `NetCDF`, preserving the variable-subset
        metadata (band dims, scale/offset, parent reference) so `sel()` and
        `read_array(unpack=True)` keep working on the view.

        A **root MDIM container** cannot be viewed lazily: a warped VRT is a
        classic single-variable raster, and warping every variable eagerly
        would contradict the lazy contract. Use :meth:`get_variable` to pick a
        variable first, or :meth:`to_crs` for an eager whole-container warp.

        Args:
            crs: Target CRS in any form :meth:`pyproj.CRS.from_user_input`
                accepts (EPSG int, ``"EPSG:3857"``, WKT, PROJ4, pyproj CRS).
            method: Resampling method used when windows are read. Defaults to
                ``"nearest neighbor"``.
            cell_size: Optional output pixel size in target-CRS units (applied
                to both axes). ``None`` keeps the source resolution.
            bbox: Optional ``(min_x, min_y, max_x, max_y)`` output extent in
                the **target** CRS; ``None`` covers the warped source extent.

        Returns:
            NetCDF: A read-only, VRT-backed reprojected view of the variable.

        Raises:
            ValueError: Called on a root MDIM container instead of a variable
                subset.

        See Also:
            NetCDF.to_crs: The eager reprojection (handles whole containers).
        """
        if self._is_root_container:
            raise ValueError(
                "warped_view works on a single variable, not a root NetCDF "
                "container — call get_variable(<name>) first and warp that, "
                "or use to_crs() for an eager whole-container reprojection."
            )
        # warped_view builds a VRT over self.raster and warps it with windowed reads. A bottom-up
        # variable's raster is a reversed AsClassicDataset view, which cannot service those reads
        # (arrayStartIdx), and a geostationary view carries a raw scan-angle geotransform. Materialize
        # first so the warp sees a plain MEM raster with the corrected geotransform.
        self._materialize_md_view()
        pinned = super().warped_view(crs, method, cell_size=cell_size, bbox=bbox)
        result = self._preserve_netcdf_metadata(pinned)
        # `Spatial.warped_view` builds the view through `self._ds.__class__`, so
        # `pinned` is already a NetCDF and `_preserve_netcdf_metadata` returns it
        # unchanged -- the pin it set is already on `result`. Re-pinning only if
        # that ever stops holding keeps the guarantee (the VRT must not outlive
        # the handle it reads through) without a self-assignment today.
        if result is not pinned:
            result._warp_source = pinned._warp_source
        return result

    def _materialize_md_view(self) -> None:
        """Replace a multidimensional ``AsClassicDataset`` view with a window-readable MEM raster.

        A variable subset's backing raster is a GDAL multidimensional ``AsClassicDataset`` view (see
        :meth:`_read_md_array`). When the raw Y axis is bottom-up, that view is a **reversed**
        ``GetView("[::-1, ...]")``, and GDAL cannot service a partial-window read through a negative
        step: it raises ``RuntimeError: arrayStartIdx[...] + (count-1)*arrayStep >= <dim>``. That
        breaks every eager partial read -- the windowed curvilinear crop, ``resample``, ``to_crs`` and
        the COG ``CreateCopy``. An unreversed view has no such problem.

        Copy the **unreversed** view into an in-memory ``MEM`` raster and apply the Y flip with NumPy,
        so no read ever passes through the reversed view.

        Idempotent; a no-op on a classic (non-multidim) subset. The lazy ``read_array(chunks=)`` path
        reads from the file directly and never touches this view, so it stays fully lazy.

        Fails soft: when neither the raw-view rebuild nor the fallback copy yields a raster, the view
        is left in place and ``_md_view_materialized`` stays ``False``, so callers that need a real
        raster (notably :meth:`_normalize_geostationary_geotransform`) can warn rather than die on an
        ``AttributeError`` from a ``None``.
        """
        if self._md_view_materialized:
            return
        # Only a variable subset carries an AsClassicDataset view. A root container's raster is the
        # multidim file dataset (0 bands) and must never be copied here.
        if not (self._is_md_array and self._is_subset) or self._raster is None:
            return
        mem = self._materialize_from_raw_view()
        if mem is None:
            # Fallback: copy through the wrapper's own raster. A full read succeeds even on a
            # reversed view; only windowed reads are unavailable there.
            mem = gdal.GetDriverByName("MEM").CreateCopy("", self._raster)
            if mem is not None:
                mem.SetGeoTransform(self._geotransform)
        if mem is None:
            # Both copies failed (a full-image allocation, so essentially only on OOM or a broken
            # source). Say so here: the view is left in place, and the callers that do not check
            # `_md_view_materialized` -- to_crs, warped_view, resample, the COG writer -- would
            # otherwise surface GDAL's opaque "arrayStartIdx[...] >= <dim>" from a windowed read of
            # a reversed view.
            warnings.warn(
                f"could not materialize the multidim view of {self._source_var_name!r}; reads that "
                "need a window (to_crs, crop, resample, COG) may fail against the reversed view.",
                stacklevel=3,
            )
            return
        # Carry the CRS across before the swap. The AsClassicDataset view often
        # has no SRS of its own — for a variable subset the projection is
        # resolved from the multidim group (a CF degrees grid) — and dropping
        # `_gdal_rg_ref` below destroys that evidence, so a materialized
        # variable would silently lose its georeference. Resolve first, then
        # stamp. (The container fallback cannot apply here: this method returns
        # early unless `_is_subset`.)
        resolved_crs = self._get_crs()
        if resolved_crs and mem.GetSpatialRef() is None:
            mem.SetProjection(resolved_crs)
        # The copy holds the view's values unchanged, so a variable that read as its store before
        # still does; carry the record `_reads_as_its_store` checks across the swap.
        if self._raster is getattr(self, "_store_raster", None):
            self._store_raster = mem
        self._raster = mem
        # The MEM copy owns its data; drop the SWIG views that backed the AsClassicDataset.
        self._gdal_md_arr_ref = None
        self._gdal_rg_ref = None
        self._md_view_materialized = True

    def _materialize_from_raw_view(self) -> gdal.Dataset | None:
        """Copy the *unreversed* multidim view into MEM, applying any Y flip with NumPy.

        ``_read_md_array`` reverses a bottom-up Y axis (and an east-to-west X axis) lazily via
        ``MDArray.GetView("[::-1, ...]")``. Reading a window through that negative-step view raises
        ``arrayStartIdx[...] >= <dim>``. Rebuild the raw view instead -- ``AsClassicDataset`` flattens
        the extra dimensions into bands exactly as before, so band order and band metadata are
        preserved -- copy it, then reverse the same axes with NumPy.

        Returns:
            gdal.Dataset | None: A window-readable ``MEM`` raster carrying the wrapper's geotransform
            and CRS, or ``None`` when the raw view cannot be rebuilt (no root group, no source
            variable, unresolved spatial dimensions, or a failed copy), in which case the caller
            falls back to copying ``self._raster``.
        """
        result = self._copy_raw_view()
        if result is not None:
            self._flip_bands_in_place(result)
            # CreateCopy carries the raw view's geotransform; re-apply the wrapper's, which holds the
            # north-up correction and any metre-rescaled geostationary geotransform.
            result.SetGeoTransform(self._geotransform)
            # ... and the wrapper's CRS, which the raw view may not have: `_georeference_index_subset`
            # installs a projection on a VRT over the view, and a multidim view often carries no SRS
            # at all. Rebuilding from the raw view would silently drop it.
            wrapper_srs = self._raster.GetSpatialRef()
            if wrapper_srs is not None:
                result.SetSpatialRef(wrapper_srs)
            self._reconcile_band_no_data(result)
        return result

    def _window_via_mdarray(
        self, x_off: int, y_off: int, x_size: int, y_size: int
    ) -> gdal.Dataset | None:
        """Read one raster window straight from the MDArray, as a small north-up ``MEM`` raster.

        The classic ``AsClassicDataset`` view is the wrong tool for a *small* window over a remote
        store. A variable stored ``(time, longitude, latitude)`` is presented by that view as
        ``rows=latitude, cols=longitude``, so every raster row is a strided gather and a windowed
        read costs roughly what a full one does. Measured against a 14 GB ``/vsicurl`` NetCDF-4:
        the same ``16x8x8`` window takes ~0.8 s through :meth:`osgeo.gdal.MDArray.Read` and ~7.5 s
        through the classic view. (For a *whole*-array read the classic view wins, which is why
        :meth:`_copy_raw_view` still uses it — this is a windowed-read shortcut, not a replacement.)

        The window is given in **raster** space (north-up, post-flip, matching
        :attr:`geotransform`); the raw indices are derived from it, so a reversed axis is read as a
        forward contiguous slice and reversed afterwards with NumPy.

        Args:
            x_off: Column offset of the window in raster space.
            y_off: Row offset of the window in raster space.
            x_size: Window width in columns.
            y_size: Window height in rows.

        Returns:
            gdal.Dataset | None: A ``MEM`` raster of the window carrying the sub-affine and the
            wrapper's CRS, or ``None`` when the read is not possible (no root group, no source
            variable, unresolved spatial dimensions, unknown flips, an out-of-range window, or a
            failed read) — in which case the caller keeps its existing full-read path.
        """
        plan = self._window_read_plan(x_off, y_off, x_size, y_size)
        if plan is None:
            return None
        md_arr, starts, counts, spatial = plan
        try:
            block = md_arr.ReadAsArray(array_start_idx=starts, count=counts)
        except (RuntimeError, AttributeError):
            block = None
        if block is None:
            return None
        target = self._window_raster(np.asarray(block), spatial, x_size, y_size)
        if target is not None:
            self._stamp_window_georeference(target, x_off, y_off)
        return target

    def _window_read_plan(
        self, x_off: int, y_off: int, x_size: int, y_size: int
    ) -> tuple[Any, list[int], list[int], tuple[int, int]] | None:
        """Raw ``(array, start, count, spatial)`` for a raster window, or ``None`` to decline.

        Owns every precondition of the windowed read and the raster-to-raw index mapping, so
        :meth:`_window_via_mdarray` is left with read, build, stamp.

        Args:
            x_off: Column offset of the window in raster space.
            y_off: Row offset of the window in raster space.
            x_size: Window width in columns.
            y_size: Window height in rows.

        Returns:
            tuple | None: The opened MDArray, its per-dimension start and count, and the
            ``(x_index, y_index)`` spatial pair — or ``None`` when the read must not be served.
        """
        rg = self._gdal_rg_ref
        var = self._source_var_name
        spatial = self._md_spatial_dims
        flips_known = isinstance(self._md_y_flipped, bool) and isinstance(
            self._md_x_flipped, bool
        )
        if rg is None or var is None or spatial is None or not flips_known:
            return None
        # Reading from the MDArray is only equivalent while the wrapper's raster still *is* that
        # variable. An in-place mutation (`apply(inplace=True)`, `fill`, `add_band`) swaps in a
        # raster of the same size holding different values while keeping the multidim references,
        # so every size and shape check still passes and the window would silently return the
        # pre-mutation data (#1071 review).
        if getattr(self, "_raster_diverged_from_source", False):
            return None
        x_index, y_index = spatial
        md_arr = open_mdarray(rg, var)
        if md_arr is None:
            return None
        try:
            sizes = [d.GetSize() for d in md_arr.GetDimensions()]
        except (RuntimeError, AttributeError):
            return None
        in_range = (
            0 <= x_off
            and 0 <= y_off
            and x_size > 0
            and y_size > 0
            and x_off + x_size <= sizes[x_index]
            and y_off + y_size <= sizes[y_index]
        )
        # The caller derived this window from the *wrapper* raster's rows/columns, but it is served
        # from the raw MDArray. Those agree today only through a chain of non-local invariants
        # (`_md_spatial_dims` is set once, in `get_variable`, and every reshaping operation
        # materializes the view and so disables this path). Check it here rather than trust the
        # chain: a mismatch would silently read a differently-sized grid at the same indices.
        same_grid = sizes[x_index] == self.columns and sizes[y_index] == self.rows
        if not (in_range and same_grid):
            return None
        # A reversed axis is stored back-to-front, so the raster window maps to the mirrored raw
        # slice; read it forwards (negative steps are what the reversed view cannot serve) and undo
        # the reversal on the small result later. Every non-spatial axis is read whole, from 0, so
        # it flattens into bands exactly as the classic view presents them.
        starts, counts = [0] * len(sizes), list(sizes)
        for index, (offset, size, flipped) in (
            (x_index, (x_off, x_size, self._md_x_flipped)),
            (y_index, (y_off, y_size, self._md_y_flipped)),
        ):
            starts[index] = sizes[index] - offset - size if flipped else offset
            counts[index] = size
        return md_arr, starts, counts, (x_index, y_index)

    def _window_raster(
        self,
        block: np.ndarray,
        spatial: tuple[int, int],
        x_size: int,
        y_size: int,
    ) -> gdal.Dataset | None:
        """Lay a raw window out the way the classic view does, as a ``MEM`` raster.

        Args:
            block: The raw window as read, in the variable's own dimension order.
            spatial: The ``(x_index, y_index)`` positions of the spatial axes in `block`.
            x_size: Window width in columns.
            y_size: Window height in rows.

        Returns:
            gdal.Dataset | None: The ``MEM`` raster, or ``None`` if it could not be allocated.
        """
        x_index, y_index = spatial
        # The classic view flattens every non-spatial dimension into bands, in storage order, and
        # presents the spatial pair as (rows, cols) — mirror that so band order is unchanged.
        # `moveaxis` and the flip slices are views; only the per-band `ascontiguousarray` below
        # copies, one band at a time. Reshaping to `(-1, y, x)` here instead would force a copy of
        # the whole window (the moved axes are not contiguous) and each flip another, holding the
        # window three times over for a variable with long non-spatial axes.
        moved = np.moveaxis(block, [y_index, x_index], [-2, -1])
        rows = slice(None, None, -1) if self._md_y_flipped else slice(None)
        columns = slice(None, None, -1) if self._md_x_flipped else slice(None)
        band_shape = moved.shape[:-2]
        band_count = int(np.prod(band_shape)) if band_shape else 1
        target = gdal.GetDriverByName("MEM").Create(
            "", x_size, y_size, band_count, numpy_to_gdal_dtype(moved.dtype)
        )
        if target is not None:
            for band_index in range(band_count):
                # `unravel_index` walks the non-spatial axes in C order — the same order the flat
                # reshape would have produced, so band order matches the classic view exactly.
                plane = (
                    moved[np.unravel_index(band_index, band_shape)]
                    if band_shape
                    else moved
                )
                target.GetRasterBand(band_index + 1).WriteArray(
                    np.ascontiguousarray(plane[rows, columns])
                )
        return target

    def _stamp_window_georeference(
        self, target: gdal.Dataset, x_off: int, y_off: int
    ) -> None:
        """Give a window raster the sub-affine, CRS, scale/offset and no-data of its parent.

        Args:
            target: The window raster to stamp.
            x_off: Column offset of the window in raster space.
            y_off: Row offset of the window in raster space.
        """
        gt = self._geotransform
        target.SetGeoTransform(
            (
                gt[0] + x_off * gt[1],
                gt[1],
                gt[2],
                gt[3] + y_off * gt[5],
                gt[4],
                gt[5],
            )
        )
        source = self._raster
        if source is None:
            return
        wrapper_srs = source.GetSpatialRef()
        if wrapper_srs is not None:
            target.SetSpatialRef(wrapper_srs)
        else:
            # The MDArray view often carries no SRS of its own, while the variable still knows its
            # projection. A cutline warp against a projection-less raster warns and — for a cutline
            # in another CRS — clips the wrong region, so stamp it here rather than inherit
            # nothing. Prefer the resolved WKT over the EPSG code: a CRS with no authority code (a
            # rotated pole, a spherical-earth GRIB GEOGCS) has no `epsg` to fall back on and would
            # otherwise be dropped entirely.
            wkt = self.crs
            if wkt:
                target.SetProjection(wkt)
            elif self.epsg:
                target.SetProjection(sr_from_epsg(int(self.epsg)).ExportToWkt())
        # `_copy_raw_view` gets scale/offset for free from `CreateCopy`; a hand-built MEM raster
        # does not. Carry them over, or a packed variable (`scale_factor`/`add_offset`, ordinary in
        # CF) would come back through the shortcut in raw counts while the full-read path returned
        # the same window in physical units.
        carried = min(target.RasterCount, source.RasterCount)
        for band_index in range(1, carried + 1):
            source_band = source.GetRasterBand(band_index)
            target_band = target.GetRasterBand(band_index)
            scale, offset = source_band.GetScale(), source_band.GetOffset()
            if scale is not None:
                target_band.SetScale(scale)
            if offset is not None:
                target_band.SetOffset(offset)
        self._reconcile_band_no_data(target)

    def _copy_raw_view(self) -> gdal.Dataset | None:
        """Rebuild the **unreversed** classic view of the source variable and copy it into MEM.

        Returns ``None`` when the view cannot be rebuilt (no root group, no source variable,
        unresolved spatial dimensions, unknown flips, or a failed open/copy).
        """
        result = None
        rg = self._gdal_rg_ref
        var = self._source_var_name
        spatial = self._md_spatial_dims
        flips_known = isinstance(self._md_y_flipped, bool) and isinstance(
            self._md_x_flipped, bool
        )
        if rg is not None and var is not None and spatial is not None and flips_known:
            x_index, y_index = spatial
            try:
                # Bind the MDArray to a local: AsClassicDataset returns a view whose C++ backing is
                # owned by the MDArray and the root group. A bare
                # `rg.OpenMDArray(var).AsClassicDataset(...)` frees the MDArray's SWIG wrapper at the
                # end of that statement, leaving the view dangling for the CreateCopy below (segfault
                # on Windows) -- the same trap _read_md_array documents. `raw_arr` stays referenced
                # until the copy completes, so the view outlives every read from it.
                raw_arr = open_mdarray(rg, var)
                raw_view = (
                    None
                    if raw_arr is None
                    else raw_arr.AsClassicDataset(x_index, y_index, rg)
                )
            except (RuntimeError, AttributeError):
                raw_view = None
            if raw_view is not None:
                result = gdal.GetDriverByName("MEM").CreateCopy("", raw_view)
        return result

    def _flip_bands_in_place(self, target: gdal.Dataset) -> None:
        """Reverse whichever spatial axes ``_read_md_array`` reversed, one band at a time.

        Reading the whole cube and writing back a reversed (negative-stride) view would hold the
        full raster twice over, plus the contiguous copy GDAL makes of the strided buffer;
        band-wise, the peak stays at the MEM raster plus two bands.
        """
        if self._md_y_flipped or self._md_x_flipped:
            rows = slice(None, None, -1) if self._md_y_flipped else slice(None)
            cols = slice(None, None, -1) if self._md_x_flipped else slice(None)
            for index in range(1, target.RasterCount + 1):
                band = target.GetRasterBand(index)
                band.WriteArray(np.ascontiguousarray(band.ReadAsArray()[rows, cols]))

    def _reconcile_band_no_data(self, target: gdal.Dataset) -> None:
        """Copy the wrapper's per-band no-data onto a raster built from the same MDArray.

        Both callers build `target` by re-reading the source variable: :meth:`_copy_raw_view`
        rebuilds the whole unreversed view, :meth:`_window_via_mdarray` reads a single window. Band
        order therefore already agrees in both cases, and the window read carries scale and offset
        across itself (a hand-built ``MEM`` raster gets neither for free, where ``CreateCopy``
        does). No-data is the one thing neither inherits: an ``AsClassicDataset`` view silently
        ignores ``SetNoDataValue``, but the VRT that :meth:`_georeference_index_subset` may wrap
        around it does not, so a no-data set on the wrapper would be lost. Only a value actually
        present on the wrapper is copied — never ``None`` over a value the source supplies.

        Args:
            target: The rebuilt raster to reconcile against ``self._raster``.
        """
        bands = min(target.RasterCount, self._raster.RasterCount)
        for index in range(1, bands + 1):
            no_data = self._raster.GetRasterBand(index).GetNoDataValue()
            if no_data is not None:
                target.GetRasterBand(index).SetNoDataValue(no_data)

    def resample(
        self,
        cell_size: float,
        method: str = DEFAULT_RESAMPLING,
        path: str | Path | None = None,
    ) -> NetCDF:
        """Resample the dataset to a different cell size.

        On a **root MDIM container** this resamples every variable
        and returns a new container. On a **variable subset** it
        delegates to `Dataset.resample()` and wraps the result as
        `NetCDF` to preserve variable metadata.

        Args:
            cell_size: New cell size.
            method: Resampling method. Defaults to `"nearest neighbor"`.
            path: Optional output `.nc` path. When set, the resampled container is streamed straight
                to that file one slab at a time (so the whole cube is never resident) and a
                **file-backed** `NetCDF` reading it is returned; `None` (default) builds the result
                in memory.

        Returns:
            NetCDF: Resampled container or variable subset.

        Notes:
            The cells change size, so the result sits on a **new grid** and its spatial
            axes come back named `y` / `x` rather than the source's: a cube on
            `latitude` / `longitude` reports `['time', 'x', 'y']` after a resample.

        See Also:
            - :meth:`to_crs`: Why the rename happens, and which members keep the source's
              names instead.
        """
        if self._is_root_container:
            result = self._apply_to_all_variables(
                "resample",
                {"cell_size": cell_size, "method": method},
                path=path,
            )
        else:
            # resample warps the backing raster; a multidim view can't be window-read by GDAL >= 3.13.
            self._materialize_md_view()
            result = super().resample(
                cell_size=cell_size,
                method=method,
            )
            result = self._preserve_netcdf_metadata(result)._persist_to(path)
        return cast("NetCDF", result)

    def isel(self, *, drop: bool = False, **indexers: Any) -> NetCDF:
        """Facade — :meth:`Selection.isel <pyramids.netcdf.engines.selection.Selection.isel>`.

        Args:
            drop: When `True`, drop the axes a scalar (point) selector collapsed — a
                length-one `list` or `slice`, and a pre-existing length-one dim, are kept,
                as in xarray; see the engine method.
            **indexers: `dimension=selector` pairs; see the engine method.

        Returns:
            NetCDF: The variable holding the selected bands.
        """
        return self.selection.isel(drop=drop, **indexers)

    def sel(
        self,
        *,
        method: str | None = None,
        tolerance: float | None = None,
        **kwargs: Any,
    ) -> NetCDF:
        """Facade — :meth:`Selection.sel <pyramids.netcdf.engines.selection.Selection.sel>`.

        Args:
            method: `None` for an exact match, `"nearest"` to snap.
            tolerance: The furthest a `"nearest"` snap may travel.
            **kwargs: `dimension=selector` pairs; see the engine method.

        Returns:
            NetCDF: The variable holding the selected bands.
        """
        return self.selection.sel(method=method, tolerance=tolerance, **kwargs)

    def head(self, indexers: Any = None, **indexers_kwargs: int) -> NetCDF:
        """Facade — :meth:`Selection.head <pyramids.netcdf.engines.selection.Selection.head>`."""
        return self.selection.head(indexers, **indexers_kwargs)

    def tail(self, indexers: Any = None, **indexers_kwargs: int) -> NetCDF:
        """Facade — :meth:`Selection.tail <pyramids.netcdf.engines.selection.Selection.tail>`."""
        return self.selection.tail(indexers, **indexers_kwargs)

    def thin(self, indexers: Any = None, **indexers_kwargs: int) -> NetCDF:
        """Facade — :meth:`Selection.thin <pyramids.netcdf.engines.selection.Selection.thin>`."""
        return self.selection.thin(indexers, **indexers_kwargs)

    def drop_isel(self, **indexers: Any) -> NetCDF:
        """Facade — :meth:`Selection.drop_isel <pyramids.netcdf.engines.selection.Selection.drop_isel>`."""
        return self.selection.drop_isel(**indexers)

    def drop_sel(self, *, errors: str = "raise", **labels: Any) -> NetCDF:
        """Facade — :meth:`Selection.drop_sel <pyramids.netcdf.engines.selection.Selection.drop_sel>`."""
        return self.selection.drop_sel(errors=errors, **labels)

    def sortby(self, dim: str, *, ascending: bool = True) -> NetCDF:
        """Facade — :meth:`Selection.sortby <pyramids.netcdf.engines.selection.Selection.sortby>`."""
        return self.selection.sortby(dim, ascending=ascending)

    def drop_duplicates(self, dim: str, *, keep: Any = "first") -> NetCDF:
        """Facade — :meth:`Selection.drop_duplicates
        <pyramids.netcdf.engines.selection.Selection.drop_duplicates>`.
        """
        return self.selection.drop_duplicates(dim, keep=keep)

    def squeeze(self, dim: str | None = None) -> NetCDF:
        """Facade — :meth:`Selection.squeeze <pyramids.netcdf.engines.selection.Selection.squeeze>`."""
        return self.selection.squeeze(dim)

    def expand_dims(self, dim: str, value: Any = None) -> NetCDF:
        """Facade — :meth:`Selection.expand_dims <pyramids.netcdf.engines.selection.Selection.expand_dims>`."""
        return self.selection.expand_dims(dim, value)

    def set_coords(self, names: str | Sequence[str]) -> NetCDF:
        """Facade — :meth:`Selection.set_coords <pyramids.netcdf.engines.selection.Selection.set_coords>`."""
        return self.selection.set_coords(names)

    def reset_coords(self, names: str | Sequence[str] | None = None) -> NetCDF:
        """Facade — :meth:`Selection.reset_coords
        <pyramids.netcdf.engines.selection.Selection.reset_coords>`.
        """
        return self.selection.reset_coords(names)

    def broadcast_like(self, other: Any) -> NetCDF:
        """Facade — :meth:`Selection.broadcast_like <pyramids.netcdf.engines.selection.Selection.broadcast_like>`."""
        return self.selection.broadcast_like(other)

    def broadcast_equals(self, other: Any) -> bool:
        """Facade — :meth:`Selection.broadcast_equals
        <pyramids.netcdf.engines.selection.Selection.broadcast_equals>`.
        """
        return self.selection.broadcast_equals(other)

    def rename_dims(
        self, dims: Mapping[str, str] | None = None, **dims_kwargs: str
    ) -> NetCDF:
        """Facade — :meth:`Selection.rename_dims <pyramids.netcdf.engines.selection.Selection.rename_dims>`."""
        return self.selection.rename_dims(dims, **dims_kwargs)

    def assign_coords(
        self, coords: Mapping[str, Any] | None = None, **coords_kwargs: Any
    ) -> NetCDF:
        """Facade — :meth:`Selection.assign_coords <pyramids.netcdf.engines.selection.Selection.assign_coords>`."""
        return self.selection.assign_coords(coords, **coords_kwargs)

    def drop_dims(
        self, drop_dims: str | Sequence[str], *, errors: str = "raise"
    ) -> NetCDF:
        """Facade — :meth:`Selection.drop_dims <pyramids.netcdf.engines.selection.Selection.drop_dims>`."""
        return self.selection.drop_dims(drop_dims, errors=errors)

    def update(self, other: Any) -> None:  # type: ignore[override]
        """Facade — :meth:`Selection.update <pyramids.netcdf.engines.selection.Selection.update>`."""
        self.selection.update(other)

    @classmethod
    def read_file(  # type: ignore[override]
        cls,
        path: str | Path,
        read_only: bool = True,
        open_as_multi_dimensional: bool = True,
        file_i: int = 0,
        *,
        vsi: str | None = None,
        open_options: dict[str, str] | list[str] | tuple[str, ...] | None = None,
    ) -> NetCDF:
        """Open a NetCDF file from a path, URL, or archive member.

        Plain local paths, ``/vsi*`` paths, and URL schemes
        (``http(s)://``, ``s3://``, ``gs://``, ``az://``, ``abfs://`` / ``abfss://``,
        ``file://``) are all accepted — URLs are transparently rewritten
        to GDAL's virtual filesystem. Compressed archives (``.zip`` /
        ``.tar`` / ``.tar.gz`` / ``.gz``) are detected from the
        extension; pass ``vsi=`` to be explicit about the archive kind
        (e.g. an archive without a recognised extension, or to open a
        specific member by index).

        Args:
            path: Path or URL of the ``.nc`` file or archive.
            read_only: If True, open in read-only mode. Set to False for
                write access. Defaults to True.
            open_as_multi_dimensional: If True, open with
                ``gdal.OF_MULTIDIM_RASTER`` to access the full group /
                dimension / variable hierarchy. If False, open in
                classic raster mode where each variable is a subdataset.
                Defaults to True.
            file_i: Which member to open when ``path`` is (or is forced
                to be) a multi-file archive. Default ``0``.
            vsi: Treat ``path`` as an archive of this kind and open
                member ``file_i`` from inside it: ``"zip"``, ``"tar"``
                (also ``"tar.gz"`` / ``"tgz"``), ``"gzip"`` (also
                ``"gz"``), or ``"auto"`` (infer from the extension).
                Default ``None`` — ``path`` is opened directly /
                extension-sniffed as before. GDAL's archive handlers
                key off the file-name extension, so an extension-less
                download URL must first be fetched and saved with a
                ``.zip`` name (or written to ``/vsimem/<name>.zip`` via
                :func:`osgeo.gdal.FileFromMemBuffer`).
            open_options: GDAL open options as a mapping
                (``{"HONOUR_VALID_RANGE": "NO"}``) or GDAL's native
                ``["KEY=VALUE"]`` list, forwarded to the netCDF driver and
                captured on the returned container so the reopen paths reapply
                them. Default ``None`` — no options (#1025).

                **Platform caveat for NetCDF:** GDAL's netCDF driver
                requires Linux ``userfaultfd`` to open a ``.nc`` from
                any ``/vsi*`` path (archive, ``/vsicurl/``, ``/vsimem/``
                via this route). On Windows / macOS the call raises a
                ``RuntimeError`` from GDAL pointing at the missing
                ``userfaultfd``. Use :meth:`from_bytes` to read a
                downloaded ``.nc`` from memory on those platforms.

        Returns:
            NetCDF: The opened dataset.

        Note:
            Reopening a file whose earlier lazy read still has a GDAL handle parked in the
            process-global `pyramids.base._file_manager.FILE_CACHE` would leave two live GDAL
            handles to one NetCDF, which can crash GDAL on Windows. `read_file` releases any such
            parked handle for `path` first and warns (#1224); a lazy array still referencing the
            file re-opens transparently on its next chunk read. Drop the lazy array(s) or
            `close()` the `NetCDF` before reopening to avoid the warning. See :meth:`read_array`'s
            Note for the full open-handle lifetime.

        Examples:
            - Open a plain ``.nc`` from disk and list its variables:
                ```python
                >>> from pyramids.netcdf import NetCDF
                >>> nc = NetCDF.read_file(
                ...     "tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc"
                ... )
                >>> sorted(nc.variables)
                ['Band1', 'Band2', 'Band3', 'Band4']

                ```
            - Open a NetCDF held inside a zip — ``vsi="auto"`` infers
              the archive kind from the ``.zip`` extension. GDAL's
              netCDF driver needs Linux ``userfaultfd`` to read through
              ``/vsizip/``, so the open actually succeeds only on Linux;
              the ``try`` / ``except`` keeps the doctest runnable on
              Windows / macOS too (where it falls through with the
              ``RuntimeError`` GDAL raises):
                ```python
                >>> import tempfile, zipfile
                >>> from pathlib import Path
                >>> from pyramids.netcdf import NetCDF
                >>> src = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc")
                >>> with tempfile.TemporaryDirectory() as tmp:
                ...     zpath = Path(tmp) / "noah.zip"
                ...     with zipfile.ZipFile(zpath, "w") as zf:
                ...         zf.write(src, arcname="noah.nc")
                ...     try:
                ...         nc = NetCDF.read_file(zpath, vsi="auto")
                ...         variables = sorted(nc.variables)
                ...     except RuntimeError:
                ...         variables = ["Band1", "Band2", "Band3", "Band4"]
                >>> variables
                ['Band1', 'Band2', 'Band3', 'Band4']

                ```

        See Also:
            - :meth:`from_bytes`: open a NetCDF from in-memory bytes.
            - :meth:`pyramids.dataset.Dataset.read_file`: the same
              ``vsi=`` / ``file_i=`` surface for GeoTIFFs.
        """
        # Guard against two live GDAL handles to one file (#1224): a prior lazy read may have parked
        # a handle for this path in the process-global FILE_CACHE, and opening a second handle while
        # it is parked can crash GDAL on Windows. Release the parked handle(s) first -- a lazy array
        # still referencing this file re-opens transparently on its next chunk read -- and warn so
        # the caller knows to drop that array before continued concurrent use.
        released = discard_path_handles(path)
        if released:
            warnings.warn(
                f"Reopening {os.fspath(path)!r} while {released} lazy-read handle(s) for it were "
                "still parked; the parked handle(s) were released to avoid two live GDAL handles to "
                "one file (which can crash GDAL on Windows). A lazy array still referencing this file "
                "will re-open on its next chunk read -- drop it or call close() before reopening to "
                "avoid re-parking a second handle.",
                stacklevel=2,
            )
        # Normalize once here so the captured form on the Container is the
        # KEY=VALUE list; _io.read_file re-normalizes idempotently (see the note
        # in Dataset.read_file).
        options = _io.normalize_open_options(open_options)
        src = _io.read_file(
            path,
            read_only,
            open_as_multi_dimensional,
            file_i=file_i,
            vsi=vsi,
            open_options=options,
        )
        access = "read_only" if read_only else "write"
        return Container(
            src,
            access=access,
            open_as_multi_dimensional=open_as_multi_dimensional,
            open_options=options,
        )

    @classmethod
    def from_bytes(  # type: ignore[override]
        cls,
        data: bytes | bytearray | memoryview,
        *,
        suffix: str = ".nc",
        name: str | None = None,
        read_only: bool = True,
        open_as_multi_dimensional: bool = True,
    ) -> NetCDF:
        """Open a NetCDF held in memory as a byte string.

        Writes ``data`` to a temporary GDAL ``/vsimem/`` path and opens
        it as a NetCDF — no on-disk temp file needed. Useful for HTTP
        response bodies, object-store payloads, and test fixtures.

        This is **not** a URL helper — see
        :meth:`pyramids.dataset.Dataset.from_bytes` for the rationale.
        The ``/vsimem/`` entry is removed automatically when the
        returned :class:`NetCDF` is garbage-collected.

        Args:
            data: Raw bytes of a NetCDF file.
            suffix: Extension hint for GDAL's driver detection. Defaults
                to ``".nc"``.
            name: Optional label recorded as :attr:`file_name`
                (cosmetic). Defaults to ``None``.
            read_only: Open read-only. Defaults to ``True``.
            open_as_multi_dimensional: Open with
                ``gdal.OF_MULTIDIM_RASTER`` to access the full group /
                dimension / variable hierarchy. Defaults to ``True``.

        Returns:
            NetCDF: The opened in-memory dataset.

        Raises:
            TypeError: ``data`` is not a bytes-like object.
            ValueError: GDAL could not open the bytes as a NetCDF.

        Examples:
            - Open the bytes of a NetCDF and list its variables (the bytes
              here come from a file, but could be ``requests.get(url).content``):
                ```python
                >>> from pathlib import Path
                >>> from pyramids.netcdf import NetCDF
                >>> data = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc").read_bytes()
                >>> nc = NetCDF.from_bytes(data, name="downloaded.nc")
                >>> list(nc.variables)
                ['Band1', 'Band2', 'Band3', 'Band4']
                >>> nc.epsg
                4326
                >>> nc.file_name
                'downloaded.nc'

                ```
            - An in-memory NetCDF cannot be pickled — anchor it to disk first:
                ```python
                >>> import pickle
                >>> from pathlib import Path
                >>> from pyramids.netcdf import NetCDF
                >>> data = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc").read_bytes()
                >>> try:
                ...     pickle.dumps(NetCDF.from_bytes(data))
                ... except TypeError as exc:
                ...     print("to_file" in str(exc))
                True

                ```

        See Also:
            - :meth:`read_file`: open a NetCDF from a path or URL.
            - :meth:`pyramids.dataset.Dataset.from_bytes`: the GeoTIFF variant.
        """
        src, vsi_path = _io.bytes_to_gdal(
            data,
            suffix=suffix,
            read_only=read_only,
            open_as_multi_dimensional=open_as_multi_dimensional,
        )
        try:
            obj = Container(
                src,
                access="read_only" if read_only else "write",
                open_as_multi_dimensional=open_as_multi_dimensional,
            )
        except Exception as e:
            src = None
            _io.silent_unlink(vsi_path)
            raise ValueError(
                "could not open the supplied bytes as a NetCDF dataset "
                f"(the data may be corrupt or truncated): {e}"
            ) from e
        obj._vsimem_path = vsi_path
        weakref.finalize(obj, _io.silent_unlink, vsi_path)
        if name is not None:
            obj._file_name = str(name)
        return obj

    def to_kerchunk(
        self,
        output_path,
        *,
        inline_threshold: int = 500,
        vlen_encode: str = "embed",
    ) -> dict:
        """Emit a kerchunk JSON reference manifest for this file.

        Thin forwarder to :func:`pyramids.netcdf._kerchunk_facade.to_kerchunk`
        using `self._file_name` as the source path. Requires the
        `[lazy]` optional extra.

        Args:
            output_path: Path where the manifest JSON is written.
            inline_threshold: Chunks smaller than this many bytes are
                embedded directly. Default 500.
            vlen_encode: VLEN string handling mode. Default `"embed"`.

        Returns:
            dict: The manifest dict that was written.
        """
        return to_kerchunk(
            self._file_name,
            output_path,
            inline_threshold=inline_threshold,
            vlen_encode=vlen_encode,
        )

    @classmethod
    def combine_kerchunk(
        cls,
        paths,
        output_path,
        *,
        concat_dims=("time",),
        identical_dims=("lat", "lon"),
        inline_threshold: int = 500,
    ) -> dict:
        """Emit a combined kerchunk manifest spanning many NetCDFs.

        Thin forwarder to
        :func:`pyramids.netcdf._kerchunk_facade.combine_kerchunk`. Requires
        the `[lazy]` optional extra.

        Args:
            paths: Sequence of NetCDF paths to combine.
            output_path: Path where the combined manifest is written.
            concat_dims: Dimension name(s) along which to concatenate.
                Default `("time",)`.
            identical_dims: Dimensions expected to match across all
                files. Default `("lat", "lon")`.
            inline_threshold: Chunks smaller than this inline bytes are
                embedded. Default 500.

        Returns:
            dict: The combined manifest.
        """
        return combine_kerchunk(
            paths,
            output_path,
            concat_dims=concat_dims,
            identical_dims=identical_dims,
            inline_threshold=inline_threshold,
        )

    @classmethod
    def open_mfdataset(
        cls,
        paths,
        variable: str,
        *,
        chunks=None,
        parallel: bool = False,
        preprocess=None,
    ):
        """Open many NetCDFs and stack `variable` into one lazy dask array.

        Thin forwarder to
        :func:`pyramids.netcdf._mfdataset.open_mfdataset`; see that
        function for the full argument contract. Requires the
        `[lazy]` optional extra.

        Args:
            paths: Glob string, explicit path, or sequence of paths.
            variable: Name of the variable to extract from each file.
            chunks: Chunk spec forwarded to
                :meth:`NetCDF.read_array`.
            parallel: Deprecated and inert; kept for backward compatibility.
                Passing a truthy value emits a `DeprecationWarning`.
            preprocess: Optional callable applied to each
                :class:`NetCDF` before extraction.

        Returns:
            dask.array.Array: Stack of shape `(n_files, *var_shape)`.
        """
        return open_mfdataset(
            paths,
            variable,
            chunks=chunks,
            parallel=parallel,
            preprocess=preprocess,
        )

    @property
    def meta_data(self) -> NetCDFMetadata:
        """Structured metadata for this NetCDF.

        Uses the GDAL Multidimensional API (groups, arrays, dimensions) when
        the file was opened with `open_as_multi_dimensional=True`. Falls
        back to the classic `NETCDF_DIM_*` parser (`dimensions.py`) when
        opened in classic mode (no root group available).

        Cached on first access. Invalidated by add_variable/remove_variable.

        Returns:
            NetCDFMetadata

        Raises:
            RuntimeError: The dataset has been closed (matching the base getter).
        """
        self._require_open()
        if self._cached_meta_data is None:
            open_options = {
                "Open Mode": "SHARED" if self.is_subset else "MULTIDIM_RASTER"
            }
            # Scope traversal to the working group so a get_group() view reports
            # its sub-group's metadata, not the whole store (ARC-12). For a normal
            # container the working group is the root group, so this is unchanged.
            self._cached_meta_data = get_metadata(
                self._raster, open_options, start_group=self._working_group()
            )
        return self._cached_meta_data

    @meta_data.setter
    def meta_data(self, value: dict[str, str] | NetCDFMetadata) -> None:
        """Set metadata on this NetCDF dataset.

        Raises:
            ReadOnlyError: The dataset is opened read-only on-disk (a bare
                `SetMetadataItem` would otherwise silently spill a PAM sidecar),
                matching `Dataset.meta_data`.
        """
        if isinstance(value, dict):
            self._require_writable("set metadata")
            for key, val in value.items():
                self._raster.SetMetadataItem(key, val)
        else:
            self._cached_meta_data = value

    def get_all_metadata(self, open_options: dict | None = None) -> NetCDFMetadata:
        """Get full MDIM metadata (uncached).

        Unlike `meta_data` (which is cached), this always re-traverses
        the GDAL multidimensional structure.

        Args:
            open_options: Driver-specific open options forwarded to
                `get_metadata()`. Defaults to None.

        Returns:
            NetCDFMetadata
        """
        result = get_metadata(
            self._raster, open_options, start_group=self._working_group()
        )
        return result

    def get_time_variable(
        self, var_name: str = "time", time_format: str = "%Y-%m-%d"
    ) -> list[str] | None:
        """Parse the time coordinate variable into formatted date strings.

        Reads the `units` attribute (e.g., `"days since 1979-01-01"`)
        from the dimension metadata and converts raw numeric values to
        human-readable date strings.

        Args:
            var_name: Name of the time dimension / variable.
                Defaults to `"time"`.
            time_format: strftime format for the output strings.
                Defaults to `"%Y-%m-%d"`.

        Returns:
            list[str] or None: Formatted time strings, or None if the
            time dimension is not found, lacks a `units` attribute, or
            carries a `units` that is not a CF time origin (e.g. asking
            for a spatial dimension, whose units are `degrees_north`).
        """
        time_stamp = None
        time_dim = self.meta_data.get_dimension(var_name)
        if time_dim is not None:
            units = time_dim.attrs.get("units")
            if units is not None:
                calendar = time_dim.attrs.get("calendar", "standard")
                time_vals = self._read_variable(var_name)
                if time_vals is not None:
                    try:
                        func = create_time_conversion_func(
                            units, time_format, calendar=calendar
                        )
                    except Exception:
                        # Guard only the parse, exactly as `_decode_time_labels` does. Every
                        # dimension now carries its `units` (#1078), so a non-temporal one no
                        # longer "lacks" the attribute and reaches this parse with something
                        # like "degrees_north". Returning None keeps the documented contract
                        # rather than turning a wrong-dimension query into an exception.
                        return None
                    # The value conversion stays outside the guard, so a genuinely malformed
                    # coordinate value still surfaces instead of being hidden.
                    time_stamp = list(map(func, time_vals.reshape(-1)))
        return time_stamp

    def _decode_time_labels(
        self,
        var_name: str,
        raw_values: list[Any],
        time_format: str = "%Y-%m-%d",
        *,
        strict: bool = True,
    ) -> list[str] | None:
        """Decode already-read raw time values into formatted date strings.

        Unlike :meth:`get_time_variable`, which reads the *full* stored axis, this
        decodes the specific `raw_values` handed to it, using the first `(units, calendar)`
        pair for `var_name` whose units parse, in the order `_time_attr_candidates` yields
        them: this cube's own dimension metadata, its parent container's, then the units a
        result computed in memory carries. A variable subset built
        by :meth:`get_variable` keeps its (possibly subsetted) raw coordinate values
        in `_band_dim_values_map` but loses the root group's dimension attributes,
        so its own metadata is empty while the parent still carries the units —
        which is why the animate frame labels came back as raw integers (#1013).
        Decoding the passed values (not the parent's full axis) keeps the labels
        aligned with a time-subsetted view.

        Args:
            var_name: Name of the time coordinate / dimension.
            raw_values: The raw coordinate values to decode (one per frame).
            time_format: strftime format for the output strings. Defaults to
                `"%Y-%m-%d"`.
            strict: When `True` (the default) a coordinate value the converter
                cannot handle propagates, so a malformed axis is not hidden behind
                raw labels. When `False` it returns `None` instead, which is what
                a selection needs: an axis that cannot be decoded simply has no labels
                and the stored-value path still answers.

        Returns:
            list[str] or None: One formatted string per value, or `None` when no
            candidate's `units` parses (a present-but-unparseable one — a non-CF
            `units` string — degrades to raw labels rather than raising out of a
            plot), or, with `strict=False`, when a value does not convert.
        """
        labels: list[str] | None = None
        for units, calendar in self._time_attr_candidates(var_name):
            try:
                func = create_time_conversion_func(
                    units, time_format, calendar=calendar
                )
            except Exception:
                # Guard only the parse: a present-but-non-CF `units` (e.g.
                # "gregorian", or "days" with no "since <origin>") cannot be parsed
                # into a time origin; `create_time_conversion_func` / cftime raise on
                # it. This is a display-label nicety, so degrade to the raw labels
                # (return None) instead of crashing the plot — honouring the "returns
                # None" contract and matching the pre-#1013 subset behaviour.
                labels = None
                continue
            # By default the value conversion runs outside the guard, so a genuinely
            # malformed coordinate value surfaces as an error rather than being hidden
            # behind a silent raw-label fallback. `strict=False` is for a *selection*,
            # where a value the converter chokes on means "this axis has no labels" and
            # the stored-value path can still answer — a `_FillValue`, an infinity or an
            # out-of-range offset must not abort the caller's `sel`. The converters fail
            # in several ways (`ValueError`, `OverflowError`, and `AttributeError` out of
            # cftime), so the non-strict arm catches broadly; the metadata resolution
            # above stays unguarded either way.
            if strict:
                labels = [func(value) for value in raw_values]
            else:
                try:
                    labels = [func(value) for value in raw_values]
                except Exception:
                    labels = None
            break
        return labels

    def _time_attr_candidates(self, var_name: str) -> Iterator[tuple[str, str]]:
        """Every `(units, calendar)` this object can find for dimension `var_name`, nearest first.

        The dimension's `units` / `calendar` attributes come first from this object's metadata
        and then its parent's, which is where a `get_variable` subset finds them. After those
        come the attributes carried alongside the band labels (`_band_dim_time_attrs`), on this
        object and then its parent: a result computed in memory — an operator result, a
        reduction, a coarsening — has no store of its own, and carries its operand's units that
        way so its stamps still decode. Only CF time units (`<period> since <origin>`, as
        `is_cf_time_units` decides) are yielded: a pressure level in `millibar` has units, but
        they neither decode a stamp nor say whether two operands' levels are the same axis.

        A closed owner's metadata is skipped rather than read, since a closed dataset refuses
        that read: a variable taken inside a `with` block carries the units `get_variable`
        resolved while its container was open, and finds them that way. A pair already yielded
        is not yielded again.

        Args:
            var_name: The dimension.

        Yields:
            tuple[str, str]: `(units, calendar)`, the calendar defaulting to `"standard"`.
        """
        owners = (self, getattr(self, "_parent_nc", None))
        # Generators, so a caller taking only the nearest pair reads no further owner.
        found = itertools.chain(
            (NetCDF._declared_time_attrs(owner, var_name) for owner in owners),
            (NetCDF._carried_time_attrs(owner, var_name) for owner in owners),
        )
        seen: set[tuple[str, str]] = set()
        for pair in found:
            if pair is not None and pair not in seen:
                seen.add(pair)
                yield pair

    @staticmethod
    def _declared_time_attrs(owner: Any, var_name: str) -> tuple[str, str] | None:
        """The CF time `(units, calendar)` `owner`'s metadata declares for `var_name`.

        Args:
            owner: The object whose metadata is read, or `None`.
            var_name: The dimension.

        Returns:
            tuple[str, str] | None: The pair, the calendar defaulting to `"standard"`; `None`
            when there is no owner, the owner is closed (its metadata cannot be read), it has no
            such dimension, or the dimension's units are not CF time units.
        """
        time_dim = (
            None
            if owner is None or owner._raster is None
            else owner.meta_data.get_dimension(var_name)
        )
        pair: tuple[str, str] | None = None
        if time_dim is not None and is_cf_time_units(time_dim.attrs.get("units")):
            pair = (time_dim.attrs["units"], time_dim.attrs.get("calendar", "standard"))
        return pair

    @staticmethod
    def _carried_time_attrs(owner: Any, var_name: str) -> tuple[str, str] | None:
        """The CF time `(units, calendar)` `owner` carries for `var_name` in `_band_dim_time_attrs`.

        Args:
            owner: The object whose carried units are read, or `None`.
            var_name: The dimension.

        Returns:
            tuple[str, str] | None: The carried pair, or `None` when there is no owner, it
            carries nothing for `var_name`, or what it carries is not CF time units.
        """
        carried = getattr(owner, "_band_dim_time_attrs", {}).get(var_name)
        return carried if carried is not None and is_cf_time_units(carried[0]) else None

    def _resolvable_time_dim_names(self) -> tuple[str, ...]:
        """The dimensions :meth:`_resolved_band_dim_time_attrs` looks for CF time units on.

        A **variable** tracks its own non-spatial axes, so those are the ones to ask about. A
        **container** tracks none — `_band_dim_names` is empty on one — so asking it the same
        question used to answer `{}` however much the store declared, and a caller reading a
        container's calendar had to reach through one of its variables to find it. Here a
        container falls back to its own non-spatial dimensions, which is the same set its
        variables span.

        Returns:
            tuple[str, ...]: The dimension names to resolve units for.
        """
        if self._band_dim_names:
            return tuple(self._band_dim_names)
        if _reduces_as_a_variable(self):
            # A *variable* with no band dimensions spans none, so there is nothing to resolve
            # units for. Falling through to the container branch had a flat variable claiming
            # the store's dimensions — harmless downstream, but it is not an axis it spans.
            return ()
        return tuple(
            name
            for name in (self.dimension_names or [])
            if name.lower() not in _SPATIAL_DIM_NAMES
        )

    def _resolved_band_dim_time_attrs(self) -> dict[str, tuple[str, str]]:
        """The nearest `(units, calendar)` of each band dimension that has one.

        What a derived result, and a variable `get_variable` builds, carries in
        `_band_dim_time_attrs`, resolved here from wherever this object finds it, so the carrier
        does not depend on this object or its parent staying alive, or staying open.

        Returns:
            dict[str, tuple[str, str]]: `{dimension: (units, calendar)}` for the band dimensions
            whose units are CF time units; a pressure level in `millibar` is left out.

        Examples:
            - Time units are resolved, a level's `millibar` is not:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> var = NetCDF.from_array(
              ...     np.ones((2, 2, 1, 1)),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1000, 850])]),
              ... ).get_variable("t")
              >>> var._band_dim_time_attrs = {
              ...     "time": ("hours since 2000-01-01", "standard"),
              ...     "level": ("millibar", "standard"),
              ... }
              >>> var._resolved_band_dim_time_attrs()
              {'time': ('hours since 2000-01-01', 'standard')}

              ```
        """
        resolved: dict[str, tuple[str, str]] = {}
        for name in self._resolvable_time_dim_names():
            nearest = next(iter(self._time_attr_candidates(name)), None)
            if nearest is not None:
                resolved[name] = nearest
        return resolved

    @property
    def dimension_sizes(self) -> dict[str, int]:
        """Logical size of every dimension, in storage order, as ``{name: size}``.

        Reads the true dimension lengths from the multidimensional root group
        (e.g. ``{"time": 128568, "y": 3840, "x": 4608}``). Prefer this over
        :attr:`shape` on a chunked cloud store: ``shape`` reflects the classic
        single-raster view (band count + a chunk-sized window), so a remote Zarr
        whose CF time axis GDAL can't parse reports ``(0, chunk_y, chunk_x)``
        rather than the logical grid. Empty for a variable subset (no root group).

        Returns:
            dict[str, int]: Mapping of dimension name to its length; ``{}`` when
            the cube is a variable subset rather than a root MDIM container.

        Examples:
            - True grid sizes of a NWM retrospective cube (needs the bucket)::

                >>> nc.dimension_sizes  # doctest: +SKIP
                {'soil_layers_stag': 4, 'time': 128568, 'vis_nir': 2, 'x': 4608, 'y': 3840}
        """
        return {
            dim.GetName(): int(dim.GetSize())
            for dim in self._working_group_dimensions()
        }

    def get_time_values(self, var_name: str = "time") -> np.typing.NDArray | None:
        """Raw (undecoded) values of the time coordinate, or ``None`` if absent.

        Use this when :meth:`get_time_variable` returns ``None`` because the
        store's CF ``units`` are not parseable — some cloud Zarr stores do not
        surface ``units`` through GDAL, so dates can't be decoded. The raw
        offsets, together with the dimension's ``calendar`` / ``units`` attributes
        (``meta_data.get_dimension(var_name).attrs``) and :attr:`dimension_sizes`,
        let a caller map a date window to integer indices for :meth:`subset` and
        detect out-of-range — instead of hard-coding an unverifiable schedule.

        Args:
            var_name: Name of the time coordinate / dimension. Defaults to
                ``"time"``.

        Returns:
            numpy.ndarray or None: The raw coordinate values, or ``None`` when
            the store has no such dimension. On a variable subset the values are
            that view's own — a time-subsetted cube reports the steps it kept, not
            the source file's whole axis.

        Examples:
            - Raw 3-hourly offsets of the NWM retrospective cube (needs the
              bucket)::

                >>> nc.get_time_values("time")[:3]  # doctest: +SKIP
                array([0, 3, 6])

        See Also:
            `get_dimension_values`: the same read for any dimension — this method is
                the time-axis spelling of it.
        """
        return self.get_dimension_values(var_name)

    def get_dimension_values(self, name: str) -> np.typing.NDArray | None:
        """Stored coordinate values of any dimension, or ``None`` if it has none.

        The general form of :meth:`get_time_values` — the accessor for a *non-spatial,
        non-time* axis (``level``, ``depth``, ``member``, …), whose values were
        previously reachable only through the optional `to_xarray` bridge or by
        provoking `sel`'s "Available values" error.

        Answers from whichever source the receiver has:

        * On a **variable subset** (what :meth:`get_variable` returns) a band dimension
          is answered from the subset's own band-dim coordinates, so after a
          :meth:`sel` the values reflect what that view actually holds — which is how a
          `sel(..., method="nearest")` caller reads back the coordinate it snapped to.
        * On a **root container** the coordinate variable is read from the store, for
          any dimension it declares. A subset asked for a dimension it does not track —
          its spatial axes — has no coordinate variable to read and answers ``None``;
          those come from the geotransform (:meth:`~pyramids.dataset.transform.GeoTransform.x_axis`).

        Values are the **stored** ones, matching what :meth:`sel` matches against and what
        ``to_xarray(decode_times=False).coords`` reports for the same file. A CF time axis
        is therefore raw offsets; :meth:`get_time_variable` decodes the same axis to date
        strings, and :meth:`to_xarray` decodes it to ``datetime64[ns]`` by default.

        Storage order is also the *array* order, which for a **spatial** axis need not be
        the raster's. Pyramids presents rasters north-up, but a south-to-north file stores
        its latitudes ascending, so ``get_dimension_values("lat")`` comes back
        ``[40, 41, …, 44]`` while :meth:`read_array`'s row 0 is the lat-44 row. Do not
        index raster rows with this — :meth:`~pyramids.dataset.transform.GeoTransform.y_axis`
        builds the row centres from the geotransform, in raster order. The values here answer
        "what does this file store", which is the question :meth:`sel` and the CF metadata ask.

        Args:
            name: Dimension name, as listed by :attr:`dimension_names` /
                :attr:`dimension_sizes` (e.g. ``"level"``).

        Returns:
            numpy.ndarray or None: The coordinate values in storage order, or ``None``
            when the dataset has no such dimension or it carries no coordinate variable.
            One caveat on a variable subset: a band dimension whose indexing variable
            cannot be read at all is tracked as the placeholder ``[0, 1, …, size - 1]``
            and comes back here as those integers rather than as ``None``. They are also
            what :meth:`sel` matches against on such an axis, so the two agree; they are
            just not the file's own labels.

        Examples:
            - Read the pressure levels of a 4-D cube, then the levels one `sel` kept::

                >>> nc.get_dimension_values("level")  # doctest: +SKIP
                array([1000.,  925.,  850.,  700.])
                >>> nc.get_variable("rhum").sel(level=850).get_dimension_values("level")  # doctest: +SKIP
                array([850.])

        See Also:
            `get_time_variable`: decodes a CF time axis to date strings.
            `sel`: selects bands by these values.
        """
        tracked = self._band_dim_values_map.get(name)
        names = self.dimension_names
        values: np.typing.NDArray | None
        if tracked is not None:
            values = np.asarray(tracked)
        elif names is not None and name in names:
            values = self._read_dimension_coordinates(name)
        else:
            values = None
        return values

    def _read_dimension_coordinates(self, name: str) -> np.typing.NDArray | None:
        """Read one dimension's coordinate variable, string-typed axes included.

        `_read_variable` goes through `ReadAsArray`, which the GDAL SWIG bindings refuse
        for a character array — a WRF `Times` axis raised `RuntimeError: String buffer
        data type not supported` straight out of the accessor. The list-based `Read()`
        path handles those, and is the one `to_xarray` already uses for the same axes, so
        falling back to it keeps the two reporting the same coordinates.

        Args:
            name: Dimension name, already known to be one this dataset declares.

        Returns:
            numpy.ndarray or None: The coordinate values, or `None` when the dimension
            has no indexing variable to read.
        """
        try:
            values = self._read_variable(name)
        except RuntimeError:
            dim = self._get_dimension(name)
            indexing_var = None if dim is None else dim.GetIndexingVariable()
            values = (
                None if indexing_var is None else self._md_array_to_numpy(indexing_var)
            )
        return values

    def _get_dimension_names(self) -> list[str] | None:
        """Return all dimension names, in storage order.

        On the root MDIM container, this reads from `GetRootGroup()`.
        On a variable subset (returned by `get_variable()`), the
        underlying raster is a classic-mode in-memory `Dataset` whose
        `GetRootGroup()` is `None`, but the source MDArray's dim names
        were captured into `_md_array_dims` at subset-build time. Fall
        through to that field so cube callers see the same public
        surface as container callers.

        Returns:
            list[str] or None: Dim names. `None` only when the cube is
            neither MDIM-backed nor has cached `_md_array_dims`.
        """
        rg = self._working_group()
        if rg is not None:
            return [dim.GetName() for dim in self._working_group_dimensions()]
        cached = getattr(self, "_md_array_dims", None)
        if cached:
            return list(cached)
        return None

    @property
    def dimension_names(self) -> list[str] | None:
        """Names of all dimensions in storage order.

        On the root MDIM container the names come from the GDAL root
        group (e.g. `["x", "y", "time"]`). On a variable subset
        returned by `get_variable()` the names come from the cached
        `_md_array_dims` captured at subset-build time, so 4-D+ cubes
        report all dims (e.g. `["valid_time", "pressure_level",
        "latitude", "longitude"]`) without touching private state.

        Returns:
            list[str] or None: Dim names. `None` only on a cube that
            has neither a root group nor cached `_md_array_dims`.
        """
        return self._get_dimension_names()

    def _get_dimension(self, name: str) -> gdal.Dimension | None:
        dim = None
        for candidate in self._working_group_dimensions():
            if candidate.GetName() == name:
                dim = candidate
                break
        return dim

    def _needs_y_flip(self, rg, md_arr) -> bool:
        """Check if an MDArray's Y dimension goes south-to-north.

        Decided from the scale/offset-applied coordinate; see `_mdim.needs_y_flip`.
        Returns False for 1-D arrays or when orientation is already correct.

        Args:
            rg: The root group (kept alive to prevent SWIG GC).
            md_arr: The MDArray to check.
        """
        return needs_y_flip(rg, md_arr)

    def _needs_x_flip(self, rg, md_arr) -> bool:
        """Check if an MDArray's X dimension goes east-to-west.

        Decided from the scale/offset-applied coordinate; see `_mdim.needs_x_flip`.
        Returns False for 1-D arrays or when orientation is already correct.

        Args:
            rg: The root group (kept alive to prevent SWIG GC).
            md_arr: The MDArray to check.
        """
        return needs_x_flip(rg, md_arr)

    def _read_variable(
        self,
        var: str,
        window: list[tuple[int, int]] | None = None,
    ) -> np.typing.NDArray | None:
        """Read a variable's data as a numpy array, optionally windowed.

        Uses the MDIM root group when available (avoids opening a new GDAL
        handle). Falls back to the classic `NETCDF:file:var` path.

        A **full** read of a 2+-dimensional array is normalized to the raster
        convention `get_variable` produces — the Y axis is reversed when the
        data is stored south-to-north and the X axis when it is stored
        east-to-west (one `_mdim.axis_flips` probe decides both). A
        **windowed** read is returned in **storage order** with no flip: the
        window is expressed in storage indices, so reordering the result
        would desync it from the indices the caller windowed by.

        Args:
            var: Variable name in the dataset.
            window: Per-dimension window as a list of `(start, count)`
                tuples, one per dimension of the target variable. For
                example, `[(0, 1), (100, 256), (200, 256)]` reads
                time[0:1], y[100:356], x[200:456]. When `None` the
                full variable is read. Only supported in MDIM mode;
                ignored in classic mode.

        Returns:
            np.ndarray or None: The variable data, or None if the
                variable is not found.

        See Also:
            _read_mdim_variable: The MDIM path (guarded MDArray read plus the
                dimension indexing-variable fallback).
            _read_classic_variable: The classic ``NETCDF:file:var`` subdataset
                path used when no MDIM working group is available.
            _read_mdarray: The windowed/full MDArray read.
            _normalize_mdarray_axes: The full-read axis-flip normalization (where
                the Y/X flip happens).
            _read_indexing_variable: The dimension indexing-variable fallback read.
        """
        rg = self._working_group()
        if rg is not None:
            result = self._read_mdim_variable(rg, var, window)
        else:
            result = self._read_classic_variable(var)
        return result

    def _read_mdim_variable(
        self, rg: gdal.Group, var: str, window: list[tuple[int, int]] | None
    ) -> np.typing.NDArray | None:
        """Read ``var`` in MDIM mode: MDArray first, indexing-variable fallback.

        The MDArray open+read+normalize is guarded against GDAL raising for a
        missing or non-numeric array; the indexing-variable fallback runs
        *outside* that guard so a genuine failure there is not swallowed. A full
        (unwindowed) >=2-D read is normalized to raster convention; a windowed
        read is returned in storage order.

        Args:
            rg: The working group ``var`` is resolved against (kept alive across
                the axis-flip probe).
            var: Variable (or dimension) name to read.
            window: Per-dimension ``(start, count)`` tuples, or ``None`` for a
                full read.

        Returns:
            np.ndarray or None: The variable data, or ``None`` if not found.

        Note:
            This method raises nothing of its own but it does not catch
            everything. The MDArray branch swallows only ``RuntimeError`` /
            ``ValueError`` (from the read or the normalization -- the resolver
            folds a missing array into ``None`` rather than raising), so any
            other exception type propagates unchanged; and the
            indexing-variable fallback runs outside that guard, so any error it
            raises propagates too rather than being masked as a missing variable.
        """
        result: np.typing.NDArray | None = None
        try:
            md_arr = open_mdarray(rg, var)
            if md_arr is not None:
                result = self._read_mdarray(md_arr, window)
                # A full >=2-D read is normalized to raster convention (row 0 =
                # north, col 0 = west); a windowed read stays in storage order.
                if result is not None and result.ndim >= 2 and window is None:
                    result = self._normalize_mdarray_axes(rg, md_arr, result)
        except (RuntimeError, ValueError):
            pass  # nosec B110
        if result is None:
            result = self._read_indexing_variable(var, window)
        return result

    @staticmethod
    def _read_mdarray(
        md_arr: gdal.MDArray, window: list[tuple[int, int]] | None
    ) -> np.typing.NDArray | None:
        """Read an MDArray as a numpy array, windowed or in full.

        A windowed read is expressed in storage indices and returned in storage
        order (the caller is responsible for any axis normalization).

        Args:
            md_arr: The GDAL MDArray to read.
            window: Per-dimension ``(start, count)`` tuples, or ``None`` for a
                full read.

        Returns:
            np.ndarray or None: The read data (``None`` only if GDAL yields it).
        """
        result: np.typing.NDArray | None
        if window is not None:
            starts = [w[0] for w in window]
            counts = [w[1] for w in window]
            result = md_arr.ReadAsArray(
                array_start_idx=starts,
                count=counts,
            )
        else:
            result = md_arr.ReadAsArray()
        return result

    @staticmethod
    def _normalize_mdarray_axes(
        rg: gdal.Group, md_arr: gdal.MDArray, result: np.typing.NDArray
    ) -> np.typing.NDArray:
        """Flip a full >=2-D read into raster convention (row 0 = north, col 0 = west).

        One :func:`axis_flips` probe decides both axes. ``rg`` is accepted (not
        just ``md_arr``) so the root group is held alive across the probe and
        SWIG cannot garbage-collect it mid-call.

        Args:
            rg: The root group, kept alive to prevent SWIG GC.
            md_arr: The MDArray the result was read from.
            result: The full read (in storage order) to normalize; ``np.flip``
                returns new views, so this array is not mutated in place.

        Returns:
            np.ndarray: The array flipped to raster convention as needed.
        """
        flip_y, flip_x = axis_flips(rg, md_arr)
        if flip_y:
            result = np.flip(result, axis=result.ndim - 2)
        if flip_x:
            result = np.flip(result, axis=result.ndim - 1)
        return result

    def _read_indexing_variable(
        self, var: str, window: list[tuple[int, int]] | None
    ) -> np.typing.NDArray | None:
        """Read ``var``'s dimension indexing variable (a 1-D coordinate).

        Used as the fallback when ``var`` is not an MDArray but names a
        dimension whose indexing variable holds the coordinate values. A
        1-element ``window`` selects a slice of that 1-D coordinate; any other
        window shape reads the coordinate in full.

        Args:
            var: The dimension name whose indexing variable to read.
            window: Per-dimension ``(start, count)`` tuples, or ``None``. Only a
                length-1 window is applied (the indexing variable is 1-D).

        Returns:
            np.ndarray or None: The coordinate values, or ``None`` when ``var``
                has no dimension or indexing variable.
        """
        result: np.typing.NDArray | None = None
        dim = self._get_dimension(var)
        if dim is not None:
            iv = dim.GetIndexingVariable()
            if iv is not None:
                if window is not None and len(window) == 1:
                    starts = [window[0][0]]
                    counts = [window[0][1]]
                    result = iv.ReadAsArray(
                        array_start_idx=starts,
                        count=counts,
                    )
                else:
                    result = iv.ReadAsArray()
        return result

    def _read_classic_variable(self, var: str) -> np.typing.NDArray | None:
        """Read ``var`` in classic (non-MDIM) mode via the subdataset string.

        Opens ``NETCDF:<file>:<var>`` and reads it in full. The ``window`` is not
        supported in classic mode and is intentionally not accepted here.

        Args:
            var: Variable name to read from the classic subdataset.

        Returns:
            np.ndarray or None: The variable data, or ``None`` when the
                subdataset cannot be opened or read.
        """
        result: np.typing.NDArray | None = None
        # Prefer the real /vsimem backing path over a cosmetic from_bytes `name=` that shadows
        # file_name, so a named in-memory classic read reopens the true source (#1057; mirrors
        # the #1050/#1053 fixes to _classic_geotransform/_geolocation_source).
        path = getattr(self, "_vsimem_path", None) or self.file_name
        try:
            ds = gdal.Open(f'NETCDF:"{path}":{var}')
            if ds is not None:
                result = ds.ReadAsArray()
            ds = None
        except (RuntimeError, AttributeError):
            pass
        return result

    def _working_group(self) -> gdal.Group | None:
        """Return the GDAL group this container is rooted at.

        For a normal container (`_group_path` is None) this is the dataset's
        root group — identical to the historical `self._raster.GetRootGroup()`.
        For a `get_group()` view it walks the `/`-joined `_group_path` from the
        root and returns the nested sub-group, so the container reads that
        group's variables, dimensions, and attributes in place without copying
        any data (ARC-12).

        The sub-group is re-resolved on every call (no memoization). This is
        deliberate: caching the resolved `gdal.Group` would hold a live
        reference to the dataset's GDAL internals, which would fight the
        handle-release contract that :meth:`close` enforces (it drops the SWIG
        refs and forces a GC so the file unlocks promptly). Re-walking
        `OpenGroup` is cheap relative to that risk, and the spatial/metadata
        queries that call this are not hot loops.

        Returns:
            The active `osgeo.gdal.Group`, or `None` when the dataset is closed,
            in classic (non-MDIM) mode, or the recorded group path no longer
            resolves.
        """
        if self._raster is None:
            return None
        rg = self._raster.GetRootGroup()
        if rg is None or not self._group_path:
            return rg
        group = rg
        for part in self._group_path.split("/"):
            try:
                group = group.OpenGroup(part)
            except RuntimeError:
                group = None
            if group is None:
                return None
        return group

    def _working_group_dimensions(self) -> list:
        """Return the dimensions visible to this container's working group.

        For a normal container this is exactly the root group's
        ``GetDimensions()``. For a `get_group()` view it also includes
        dimensions the group's variables **inherit** from an ancestor group:
        a netCDF file commonly defines shared `x`/`y` (and `time`) at the root
        and references them from sub-group variables, and a GDAL group's
        ``GetDimensions()`` lists only the dimensions declared *in* that group.
        Without this union a group view would report no dimensions for a
        variable that is plainly 2-D (the pre-`get_group`-view behaviour
        recreated the inherited dimensions, so this preserves it).

        Returns:
            list: GDAL ``Dimension`` objects, de-duplicated by name, in group
            order followed by any inherited dimensions discovered on the
            group's variables.
        """
        rg = self._working_group()
        if rg is None:
            return []
        dims = list(rg.GetDimensions())
        if not self._group_path:
            # Normal container: the root group's dimensions are complete, and
            # scanning every variable would be needless work on a hot path.
            return dims
        seen = {dim.GetName(): dim for dim in dims}
        for var in rg.GetMDArrayNames() or []:
            md_arr = rg.OpenMDArray(var)
            if md_arr is None:
                continue
            for dim in md_arr.GetDimensions():
                seen.setdefault(dim.GetName(), dim)
        return list(seen.values())

    @property
    def group_names(self) -> list[str]:
        """Names of sub-groups in the root group.

        Returns:
            list[str]: Sub-group names (e.g. `["forecast", "analysis"]`).
            Empty list if no sub-groups exist or the dataset is in
            classic mode.
        """
        rg = self._working_group()
        result = []
        if rg is not None:
            try:
                names = rg.GetGroupNames()
                if names:
                    result = list(names)
            except RuntimeError:
                pass
        return result

    def get_group(self, group_name: str) -> NetCDF:
        """Open a sub-group as a NetCDF container, without copying its data.

        The returned :class:`Container` is a **zero-copy view** (ARC-12): it
        shares this container's open GDAL dataset and records the path to the
        sub-group, rather than materialising every array into a new in-memory
        store. Variables, dimensions, and attributes are read from the
        sub-group in place, and each variable's data is materialised only when
        it is extracted via `get_variable` — so opening a group of large
        variables no longer reads them all into memory up front.

        (The lazy `read_array(chunks=)` dask path resolves a variable from the
        store's root group, so it does not yet reach a sub-group variable — the
        same pre-existing limitation as the group-qualified
        `get_variable("group/var")` path; eager reads work as usual.)

        The view holds its own reference to the shared dataset and keeps this
        parent container alive, so closing either side only drops a reference;
        the underlying GDAL dataset is freed once both are released.

        Thread-safety: because the view shares the parent's `gdal.Dataset`
        (GDAL datasets are not thread-safe), do **not** read the parent and a
        view — or two views of the same store — concurrently from different
        threads. The `read_array(threadsafe=True)` per-thread-handle path does
        not cover this shared multidimensional handle; for concurrent access,
        open the file independently per thread instead.

        Args:
            group_name: Name of the sub-group. Supports nested paths
                separated by `/` (e.g. `"forecast/surface"`). Applied
                relative to this container's current group, so `get_group`
                can be chained.

        Returns:
            NetCDF: A `Container` view backed by the sub-group.

        Raises:
            ValueError: If the group doesn't exist or the dataset
                has no root group.
        """
        rg = self._working_group()
        if rg is None:
            raise ValueError("get_group requires a multidimensional container.")

        # Validate that the requested (possibly nested) path resolves from the
        # current working group before building the view.
        group = rg
        for part in group_name.split("/"):
            try:
                group = group.OpenGroup(part)
            except RuntimeError:
                group = None
            if group is None:
                raise ValueError(
                    f"Group '{group_name}' not found. "
                    f"Available groups: {self.group_names}"
                )

        # Zero-copy view: share the parent's open dataset and record the path to
        # the sub-group. `_working_group()` resolves it on demand, so no array
        # data is copied. Compose paths so get_group() chains on an existing view.
        view = Container(self._raster, access=self._access)
        view._group_path = (
            group_name if not self._group_path else f"{self._group_path}/{group_name}"
        )
        # Pin the parent so the shared dataset (and its SWIG wrappers) outlive the
        # view even if the caller drops the parent reference.
        view._parent_nc = self
        # A group view's pickle recipe reopens the parent file, so it inherits the
        # parent's captured GDAL open options (#1025).
        view._open_options = self._open_options
        return view

    def get_variable_names(self) -> list[str]:
        """Deprecated alias for the :attr:`variable_names` property (API-3).

        Returns:
            list[str]: Same value as :attr:`variable_names`.
        """
        warnings.warn(
            "get_variable_names() is deprecated; use the `variable_names` property "
            "instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.variable_names

    def _get_variable_names(self, declared: list[str] | None = None) -> list[str]:
        """Return names of data variables, excluding dimension coordinates.

        Queries `GetMDArrayNames()` and filters out dimension arrays and
        0-dimensional scalar variables (grid_mapping etc.). In classic mode,
        parses subdataset metadata.

        This is an enumeration of **data** variables, so CF arrays that are not
        data are absent: bounds (`lat_bnds`), ancillary variables (`DQF`), cell
        measures, UGRID connectivity, and 2-D curvilinear coordinate fields
        (`lat_rho`). They remain readable by name through :meth:`get_variable`,
        which checks :meth:`_readable_variable_names` instead -- the two answer
        different questions and only this one is an enumeration.

        The classification is consulted on *every* call, not only when
        `meta_data` happens to be cached already. Preferring it conditionally
        made the answer depend on whether anything had read `meta_data` first --
        and `__str__` reads it, so merely printing or logging a container
        changed which variables the object reported.

        Two cases keep the store-derived answer, both because the CF answer
        would be wrong rather than merely poorer:

        * **Classic mode** (`rg is None`), where the CF list is empty, so every
          variable in the file would look unreachable.
        * **A sub-group view**, whose cached metadata keys variables by full
          store path (`"forecast/temperature"`) while the view's own API uses
          names relative to its group (ARC-12 review H1).

        Args:
            declared: The store's own array list, when the caller has already
                walked for it. :meth:`_readable_variable_names` needs both that
                list and this one, and used to get them from two separate walks
                -- so every readability check opened every MDArray in the store
                twice, and :meth:`get_variable` makes one such check per call,
                which put the second walk inside the fan-out's per-variable
                loop. Passing it in removes the second walk. ``None`` (the
                default) walks here, which is what :attr:`variable_names` does.
                Ignored in classic mode, which has no MDIM group to walk.

        Returns:
            list[str]: Variable names (e.g., `["temperature", "precipitation"]`).
        """
        rg = self._working_group()
        if rg is None:
            names = self._classic_subdataset_variable_names()
        else:
            if declared is None:
                declared = self._mdim_data_variable_names(rg)
            if self._group_path is not None:
                names = declared
            else:
                cf = self.meta_data.cf
                classified = set(cf.data_variable_names) if cf is not None else set()
                # The classification says *which* names are data variables; the
                # store says in what order. Reporting the classification's own
                # order (it is sorted) reordered every container:
                # `_fan_out_eager` templates the result from the *first*
                # spatial variable, taking its geotransform, CRS, no-data and
                # extra dimensions, so a different first name silently changes
                # the output container -- and the variable order propagates on
                # into `to_netcdf` and `to_xarray`. Filtering the declared list
                # keeps both.
                filtered = [n for n in declared if n in classified]
                # An empty classification means the store declares no CF roles
                # at all, not that it holds no data -- fall back rather than
                # report nothing. Same for a classification naming nothing the
                # store declares at this level.
                names = filtered or declared
        return names

    def _readable_variable_names(self, rg: Any = None) -> list[str]:
        """Every array name :meth:`get_variable` will accept.

        A superset of :attr:`variable_names`. That property *enumerates* data
        variables, so it leaves out the CF arrays that are not data -- 2-D
        curvilinear coordinate fields, bounds, ancillary variables. Those are
        still real arrays in the store, and reading one by name is a legitimate
        thing to do, so reachability is decided against the store rather than
        against the enumeration.

        "Accept" means :meth:`get_variable` resolves the name instead of
        raising. It does **not** promise a uniform return type: a name here
        that GDAL cannot expose as a raster -- a 1-D array (`band_id`,
        `time_bounds`, a coordinate axis) or a non-numeric one -- comes back as
        a :class:`~pyramids.netcdf.LabeledArray` rather than a
        :class:`~pyramids.netcdf.NetCDF` variable, as documented on
        :meth:`get_variable`. :meth:`read_array` handles both.

        Args:
            rg: An already-resolved working :class:`osgeo.gdal.Group`. When
                ``None`` (the default) it is resolved here; callers that
                already hold one pass it in so the answer is taken against the
                same group they are working from.

        Returns:
            list[str]: Names readable through :meth:`get_variable` -- the
                enumerated ones first, then anything else the store holds.
        """
        if rg is None:
            rg = self._working_group()
        declared: list[str] = [] if rg is None else self._mdim_data_variable_names(rg)
        # One walk, used twice: `_get_variable_names` filters this same list by
        # the CF classification, so handing it over removes the second walk this
        # method used to cost -- and `get_variable` pays that cost once per call.
        #
        # The hand-over is only valid when :attr:`variable_names` is the
        # inherited property, because then it *is* `_get_variable_names`. A
        # subclass that overrides the property answers a different question
        # (splicing names in, or narrowing the list), and its answer is not this
        # walk's, so it is asked directly and the walk is simply paid twice --
        # the correctness of the override outranks the saving.
        if type(self).variable_names is NetCDF.variable_names:
            readable = list(self._get_variable_names(declared=declared))
        else:
            readable = list(self.variable_names)
        for name in declared:
            if name not in readable:
                readable.append(name)
        return readable

    @staticmethod
    def _group_data_array_names(rg, prefix: str) -> list[str]:
        """The data arrays of one group, its sub-groups left alone.

        Split out of :meth:`_mdim_data_variable_names`, which was doing two
        jobs in one body: deciding what counts as data *here*, and walking
        what lies below. The two share only the prefix, and separating them is
        what keeps either readable.

        An array is dropped when it is a coordinate variable -- its name
        matches a dimension of its own group, or one it is indexed by -- or
        when it has no dimensions at all, which is how a `grid_mapping` holder
        and other scalar attribute carriers appear. An array that will not
        open is kept: it is not this function's place to hide a name because
        the driver would not hand back the array, and the caller's own error
        is clearer than an absence.

        Args:
            rg: The :class:`osgeo.gdal.Group` to read.
            prefix: Group path to prepend, empty at the root.

        Returns:
            list[str]: Prefixed names of the group's own data arrays, in the
                order the driver lists them.
        """
        group_dim_names = {dim.GetName() for dim in rg.GetDimensions()}
        names = []
        for var in rg.GetMDArrayNames():
            if var in group_dim_names:
                continue
            md_arr = rg.OpenMDArray(var)
            if md_arr is None:
                names.append(f"{prefix}{var}")
                continue
            own_dims = md_arr.GetDimensions()
            if own_dims and var not in {dim.GetName() for dim in own_dims}:
                names.append(f"{prefix}{var}")
        return names

    @staticmethod
    def _mdim_data_variable_names(rg, prefix: str = "", depth: int = 0) -> list[str]:
        """Data-variable names from an MDIM group and every group beneath it.

        Drops dimension coordinate arrays and 0-dimensional scalar variables
        (e.g. `grid_mapping` holders) so only true data variables remain.

        **Recurses into sub-groups**, naming what it finds there by the
        group-qualified path :meth:`get_variable` already accepts
        (`"flight_03/CO"`). A NetCDF-4 store that puts each of its variables in
        its own group is otherwise reported as holding only whatever sits at
        the root -- one name out of thirty on the suite's own grouped fixture --
        so anything iterating this to convert, export or plot such a file
        silently processed a single variable.

        An array counts as a coordinate variable when its name matches a
        dimension of its **own group** *or* a dimension **it is indexed by**.
        The second half matters only below the root: netCDF-4 dimensions are
        visible in every descendant group, and a sub-group that declares none
        of its own still holds coordinate arrays for its parents' -- which the
        group-declared test alone could not see, so a sub-group's axis was
        enumerated as data while the root's identical axis was not. It also
        made a `get_group(...)` view disagree with its own container about one
        group, because the view resolves the sub-group as its working group and
        finds no declared dimensions there either. Reading the array's own
        dimensions gives both the same answer, and costs one `OpenMDArray` per
        coordinate array. Measured identical to the group-declared test alone
        on all 27 netCDF fixtures in the suite.

        Args:
            rg: The :class:`osgeo.gdal.Group` to enumerate.
            prefix: Group path to prepend, used by the recursion. Empty at the
                root, so the common flat store is unaffected.
            depth: Recursion depth, used by the recursion. Sub-groups below
                :data:`_MAX_GROUP_DEPTH` are not descended into.

        Returns:
            list[str]: Names of the data variables in `rg` and its sub-groups,
                the group's own first.

        Warns:
            UserWarning: When :data:`_MAX_GROUP_DEPTH` is reached and `rg`
                still has sub-groups. Their variables are left out of every
                listing, export and conversion built from this walk, and that
                used to happen with no signal at all.
        """
        filtered = NetCDF._group_data_array_names(rg, prefix)
        sub_group_names = rg.GetGroupNames() or []
        if depth < _MAX_GROUP_DEPTH:
            for group_name in sub_group_names:
                sub = rg.OpenGroup(group_name)
                if sub is not None:
                    filtered.extend(
                        NetCDF._mdim_data_variable_names(
                            sub, f"{prefix}{group_name}/", depth + 1
                        )
                    )
        elif sub_group_names:
            skipped = ", ".join(f"{prefix}{name}/" for name in sub_group_names)
            warnings.warn(
                f"Group nesting deeper than {_MAX_GROUP_DEPTH} is not "
                f"enumerated: the variables under {skipped} are missing from "
                "`variable_names`, and from every export or conversion built "
                "from it.",
                # Not a literal: this warning is raised `_MAX_GROUP_DEPTH`
                # frames inside its own recursion, so *any* fixed level names a
                # pyramids frame -- `stacklevel=2` named this function calling
                # itself, and a level computed from `depth` would only reach
                # `_get_variable_names`, one frame further in. The shared
                # helper walks out of the package to the caller's own line,
                # which is the only frame worth naming.
                stacklevel=_caller_stacklevel(),
            )
        return filtered

    def _classic_subdataset_variable_names(self) -> list[str]:
        """Data-variable names parsed from classic-mode subdataset metadata.

        Reads the shared :attr:`~pyramids.dataset.abstract_dataset.RasterBase.subdatasets`
        surface rather than parsing ``GetSubDatasets()`` a second time.

        Returns:
            list[str]: The variable name from each :attr:`subdatasets` entry (the
            second whitespace-delimited token of its GDAL description).
        """
        return [sub.description.split(" ")[1] for sub in self.subdatasets]

    @staticmethod
    def _dimension_index(dim_names: list[str], target: str) -> int:
        """Index of a dimension matched by full name or short (leaf) name."""
        if target in dim_names:
            result = dim_names.index(target)
        else:
            short = target.lstrip("/").split("/")[-1]
            match = next(
                (
                    i
                    for i, name in enumerate(dim_names)
                    if name.lstrip("/").split("/")[-1] == short
                ),
                None,
            )
            if match is None:
                raise ValueError(
                    f"dimension {target!r} not found; available dimensions: {dim_names}"
                )
            result = match
        return result

    def _resolve_spatial_dims(
        self, md_arr, x_dim: str | None = None, y_dim: str | None = None
    ) -> tuple[int, int]:
        """Resolve the ``(x_index, y_index)`` raster-plane dimension indices.

        Precedence: explicit ``x_dim``/``y_dim`` names → CF auto-detection from the
        coordinate variables' ``axis``/``standard_name``/``units`` attributes → the
        last-two-dimensions default. This lets variables whose latitude/longitude are
        not the trailing dimensions (e.g. CAM ``T(time, lat, lev, lon)``) be read as a
        proper lat/lon plane instead of a lev/lon cross-section.
        """
        # Guard-clause/early-return style is intentional: a single-return rewrite nests the
        # detection and fallback branches and exceeds SonarCloud's S3776 complexity threshold.
        names = [d.GetName() for d in md_arr.GetDimensions()]
        n = len(names)

        explicit_x = self._dimension_index(names, x_dim) if x_dim is not None else None
        explicit_y = self._dimension_index(names, y_dim) if y_dim is not None else None
        if explicit_x is not None and explicit_y is not None:
            if explicit_x == explicit_y:
                raise ValueError(
                    f"x_dim and y_dim must be different dimensions; both resolved to "
                    f"index {explicit_x}. Dimensions: {names}"
                )
            return explicit_x, explicit_y

        detected_x, detected_y = detect_axis_indices(md_arr.GetDimensions())
        # Adopt detection only when it (combined with any single explicit side) yields a
        # complete, distinct (X, Y) pair — otherwise a partially-detected axis must not
        # collide with the last-two fallback (e.g. a 2-D `lon_bnds(lon, bnds)` variable).
        candidate_x = explicit_x if explicit_x is not None else detected_x
        candidate_y = explicit_y if explicit_y is not None else detected_y
        if (
            candidate_x is not None
            and candidate_y is not None
            and candidate_x != candidate_y
        ):
            return candidate_x, candidate_y

        # Fallback: the last two dimensions, keeping a non-colliding explicit side.
        x_index = explicit_x if explicit_x is not None else n - 1
        y_index = explicit_y if explicit_y is not None else n - 2
        if x_index == y_index:
            x_index, y_index = n - 1, n - 2
        return x_index, y_index

    def _read_md_array(
        self, variable_name: str, x_dim: str | None = None, y_dim: str | None = None
    ):
        """Convert an MDArray to a classic GDAL dataset via AsClassicDataset.

        The X (columns) and Y (rows) dimensions are resolved by
        `_resolve_spatial_dims` (explicit `x_dim`/`y_dim`, else CF
        auto-detection, else the last two dimensions); all remaining
        dimensions are flattened into bands.

        A spatial axis stored against GDAL's raster convention — Y south-to-north (bottom-up), or
        X east-to-west — is reversed via `MDArray.GetView()` **before** the conversion, so the
        raster comes back `row 0 = north, col 0 = west`. This is a lazy, zero-copy operation — GDAL
        handles the reversed indexing internally without reading the whole array, and corrects the
        view's geotransform to match. See :meth:`_y_axis_is_bottom_up` /
        :meth:`_x_axis_is_right_to_left` for how each decision is made.

        A **non-numeric** variable — a string/character column or a compound (struct) type,
        neither of which GDAL can expose as a raster — is returned as the raw `MDArray`
        whatever its rank, exactly like a 1-D variable (#1067). That raw handle stops here:
        :meth:`get_variable`, this method's only caller, materialises it into a
        :class:`~pyramids.netcdf.LabeledArray` rather than letting it out (#1126).

        Returns a tuple `(classic_dataset, md_array, root_group, x_index,
        y_index, y_flipped, x_flipped)` so callers can keep the GDAL objects alive and
        reuse the resolved plane indices (`x_index`/`y_index` are `None` for a
        1-D or non-numeric variable). `y_flipped` / `x_flipped` record which axes were reversed
        here; the eager materialize path needs them to re-apply the same flips to the unreversed
        array.
        `AsClassicDataset` returns a **view** whose C++ backing depends on the
        MDArray and root group; if the Python SWIG wrappers for those are
        garbage-collected the view becomes a dangling pointer (segfault on
        Windows).

        Raises:
            RuntimeError: When the container resolves to no working group.
            ValueError: When `variable_name` names no array in the store. The
                name is resolved through :func:`~pyramids.netcdf._mdim.open_mdarray`,
                so a group-qualified name (`"flight_03/CO"`) is walked down to
                its owning group first.
        """
        rg = self._working_group()
        # This MDIM read path is only reached for a multidim container, which
        # always resolves to a working group; guard explicitly (rather than
        # `assert`, which `python -O` would strip) so a None never reaches
        # the resolver as a bare AttributeError.
        if rg is None:
            raise RuntimeError("No working group resolved for the MDIM read.")
        md_arr = open_mdarray(rg, variable_name)
        if md_arr is None:
            raise ValueError(f"{variable_name} is not an array of this store.")
        dims = md_arr.GetDimensions()

        if len(dims) == 1:
            # A classic 2-D raster view needs >=2 dimensions, so AsClassicDataset cannot represent a
            # 1-D variable (a coordinate axis or a 1-D data series). Return the MDArray itself, matching
            # the 1-D string path and avoiding GDAL's "Invalid iXDim and/or iYDim" error (#582).
            return md_arr, md_arr, rg, None, None, False, False

        if md_arr.GetDataType().GetClass() != gdal.GEDTC_NUMERIC:
            # `AsClassicDataset` exposes numeric arrays only ("Only arrays with numeric data types
            # can be exposed as classic GDALDataset"), so the test is on the dtype *class* — string
            # (however the driver exposes a character column) or compound — not on any particular
            # shape. Rank alone cannot save such an array: `_resolve_spatial_dims` resolves a pair
            # for anything with >= 2 dimensions (its last-two fallback never declines), which is how
            # a character column reached the raster view and raised (#1067). Return the MDArray
            # itself — the same shape the 1-D branch above returns — for `get_variable` to
            # materialise into a `LabeledArray` (#1126).
            return md_arr, md_arr, rg, None, None, False, False

        # Resolve on the original array — the flips below rename the reversed dimensions and drop
        # their indexing variables, so the indices must be computed before flipping and returned to
        # the caller.
        x_index, y_index = self._resolve_spatial_dims(md_arr, x_dim, y_dim)

        # First pass: check whether either spatial axis needs reversing.
        src = md_arr.AsClassicDataset(x_index, y_index, rg)

        y_flipped = self._y_axis_is_bottom_up(dims, y_index, src)
        x_flipped = self._x_axis_is_right_to_left(dims, x_index, src)
        if y_flipped or x_flipped:
            # Bottom-up (south-to-north) storage is the NetCDF convention; an east-to-west X is not,
            # but is legal CF. Use GetView to reverse the offending dimensions — this is lazy and
            # zero-copy; GDAL handles reversed indexing internally.
            reversed_dims = {y_index: y_flipped, x_index: x_flipped}
            slices = ",".join(
                "::-1" if reversed_dims.get(i) else ":" for i in range(len(dims))
            )
            md_arr = md_arr.GetView(f"[{slices}]")
            src = md_arr.AsClassicDataset(x_index, y_index, rg)

        return src, md_arr, rg, x_index, y_index, y_flipped, x_flipped

    @staticmethod
    def _scaled_axis_ascends(dims, index: int) -> bool | None:
        """Whether the axis' scaled coordinate increases; see `_mdim.scaled_axis_ascends`."""
        return scaled_axis_ascends(dims, index)

    @staticmethod
    def _y_axis_is_bottom_up(dims, y_index: int, classic_view) -> bool:
        """Whether the Y axis is stored south-to-north; see `_mdim.y_axis_is_bottom_up`."""
        return y_axis_is_bottom_up(dims, y_index, classic_view)

    @staticmethod
    def _x_axis_is_right_to_left(dims, x_index: int, classic_view) -> bool:
        """Whether the X axis is stored east-to-west; see `_mdim.x_axis_is_right_to_left`."""
        return x_axis_is_right_to_left(dims, x_index, classic_view)

    def _declares_raster_plane(self, variable_name: str) -> bool:
        """Whether `get_variable(variable_name)` would return a raster, without reading it.

        Args:
            variable_name: The variable, group-qualified if it lives in a group.

        Returns:
            bool: `True` for a raster variable, and also when there is no
            multidimensional group to ask -- the classic `NETCDF:file:var` path
            answers with a raster or not at all -- or the name cannot be opened,
            so that `get_variable` is left to produce its own error for it.
        """
        rg = self._working_group()
        md_arr = open_mdarray(rg, variable_name) if rg is not None else None
        return md_arr is None or _has_raster_plane(md_arr)

    def _require_raster_variable(
        self, variable_name: str, x_dim: str | None = None, y_dim: str | None = None
    ) -> NetCDF:
        """`get_variable`, for the callers that can only work on a raster plane.

        Most internal users of `get_variable` go straight on to `crop`,
        `read_array`, `plot` or the band-dimension bookkeeping, none of which a
        non-raster variable has. Before this they received a raw `gdal.MDArray`
        and failed with `AttributeError: 'MDArray' object has no attribute
        'crop'`; now they would fail the same way on a `LabeledArray`. Routing
        them through here turns that into a refusal that says which variable and
        what to use instead.

        Args:
            variable_name: The variable to read.
            x_dim: Optional name of the X dimension, passed through.
            y_dim: Optional name of the Y dimension, passed through.

        Returns:
            NetCDF: The variable subset, which is raster-backed.

        Raises:
            ValueError: The variable has no raster plane -- decided from its
                declaration, without reading it, whenever it is a name
                `get_variable` accepts -- or `get_variable` itself refuses the
                name (a coordinate array, or one the store does not hold).
        """
        rg = self._working_group()
        # Only a name `get_variable` accepts is judged on its declaration. A
        # coordinate array (`x`, `lat`) or a 0-D `crs` holder has a declaration
        # too, and judging it here refused it for having "no raster plane" and
        # sent the caller to `get_variable` -- which then rejected the name
        # outright. Leaving such a name to `get_variable` gives its real refusal.
        md_arr = (
            open_mdarray(rg, variable_name)
            if rg is not None and variable_name in self._readable_variable_names()
            else None
        )
        # Decided on the declaration first, so the refusal costs nothing: going
        # straight to `get_variable` would read the whole array only to reject it.
        dims = (
            tuple(dim.GetName() for dim in md_arr.GetDimensions())
            if md_arr is not None and not _has_raster_plane(md_arr)
            else None
        )
        variable: NetCDF | LabeledArray | None = None
        if dims is None:
            variable = self.get_variable(variable_name, x_dim=x_dim, y_dim=y_dim)
            if isinstance(variable, LabeledArray):
                dims = variable.dims
        if dims is not None or not isinstance(variable, NetCDF):
            raise ValueError(
                f"{variable_name} has no raster plane (dimensions {dims}), so it "
                "has no geometry to operate on; read its values with "
                "`get_variable`, or open the store with `LabeledDataset` for "
                "labelled point data"
            )
        return variable

    def _get_group_variable(
        self, variable_name: str, x_dim: str | None, y_dim: str | None
    ) -> NetCDF | LabeledArray:
        """Resolve a group-qualified `get_variable` name through its group.

        Args:
            variable_name: A name of the form `group/leaf`.
            x_dim: Optional name of the X dimension, passed through.
            y_dim: Optional name of the Y dimension, passed through.

        Returns:
            NetCDF | LabeledArray: What the owning group's `get_variable` returns
            for the leaf, with a `LabeledArray` renamed to the qualified name.
        """
        group_path, leaf = variable_name.rsplit("/", 1)
        cube = self.get_group(group_path).get_variable(leaf, x_dim=x_dim, y_dim=y_dim)
        if isinstance(cube, LabeledArray):
            # The group resolved the leaf, so the wrapper came back named
            # `air_press` -- a name the grouped fixture repeats in every group,
            # and one `read_array(variable=...)` on the root then rejects. The
            # caller's qualified name is the one that means something here.
            cube.name = variable_name
        return cube

    def get_variable(
        self, variable_name: str, x_dim: str | None = None, y_dim: str | None = None
    ) -> NetCDF | LabeledArray:
        """Extract a single variable as a classic-raster NetCDF object.

        A variable GDAL cannot expose as a raster plane answers as a
        `LabeledArray` instead of a `NetCDF`; everything below describes the
        raster case, and `Returns` sets out the other one.

        The returned object carries origin metadata so modified data
        can be written back via `set_variable()`. Every non-spatial
        dim of the variable is tracked: for an N-D MDIM array
        `(d_0, ..., d_{n-1}, lat, lon)` the build path populates
        `_band_dim_names`, `_band_dim_values_map`, and
        `_band_dim_sizes` with all non-spatial dims in storage order,
        while the legacy `_band_dim_name` / `_band_dim_values` keep
        pointing at the first non-spatial dim so existing 3-D
        consumers see no change. 4-D+ files (e.g. CDS-Beta ERA5
        pressure-levels with `(valid_time, pressure_level, lat, lon)`)
        are addressable via `sel()` along any tracked band dim.

        Supports group-qualified names: `"forecast/temperature"` first
        navigates to the `forecast` sub-group, then extracts
        `temperature` from it.

        Args:
            variable_name: Name of the variable to extract. Use `/`
                to separate group path from variable name.
            x_dim: Dimension to map to the raster X axis (columns).
                When omitted, the longitude dimension is auto-detected
                from the coordinate variables' CF attributes (`axis`,
                `standard_name`, `units`), falling back to the last
                dimension. Use this for files whose lon/lat are not the
                trailing dims and lack CF axis metadata.
            y_dim: Dimension to map to the raster Y axis (rows). When
                omitted, the latitude dimension is auto-detected, else
                the second-to-last dimension is used.

                Both are ignored for a variable that has no raster plane to
                map them onto — a 1-D or non-numeric one (see `Returns`).

        Returns:
            NetCDF | LabeledArray: A subset backed by a classic dataset where every
                non-spatial dimension is mapped onto bands. The new
                `_band_dim_names` / `_band_dim_values_map` /
                `_band_dim_sizes` fields drive `sel()`; the legacy
                `_band_dim_name` / `_band_dim_values` track the first
                non-spatial dim.

                A variable GDAL cannot expose as a raster comes back as a
                `LabeledArray` — `values` alongside the `dims` and `shape`
                GDAL declares, plus the `name`, `unit`, `no_data_value`,
                `attributes`, `scale` and `offset` the array carries — rather
                than as a `NetCDF`: a 1-D variable of any dtype, and any
                **non-numeric** variable of any rank, a character/string
                column (however the driver exposes it) or a compound (struct)
                type. It is not a raster, so none of the band-dim tracking
                above applies to it and neither do `sel()` or the raster
                methods; read the values off `.values` (#1126). They are the
                stored values: no CF time decoding, and no `scale_factor` /
                `add_offset` applied unless you ask `read_array` for
                `unpack=True` -- the wrapper's `scale` / `offset` say what
                unpacking needs. Each call returns a fresh, writeable wrapper;
                the `variables` mapping hands out a frozen, shared one.

                `LabeledDataset["var"]` hands back the same class from the
                same store, so the two agree on type — but it decodes a CF
                time axis, applies that store's current selection, squeezes
                the scalar-selected dimensions out, and leaves the label
                fields above unset. Do not read the two as interchangeable.

        Raises:
            ValueError: If `variable_name` is not present in the dataset, or it
                names a compound array that cannot be materialised -- one with a
                string field, which GDAL's Python bindings cannot read.

        Notes:
            String-typed indexing variables (e.g. WRF's `Times` array)
            cannot be read via GDAL SWIG bindings; the build path falls
            back to integer indices `[0, 1, ..., size - 1]` for those
            dims.

        Examples:
            - A variable with a raster plane comes back as a `NetCDF` subset:
                ```python
                >>> import numpy as np
                >>> from osgeo import gdal
                >>> from pyramids.netcdf import Container
                >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
                >>> group = store.GetRootGroup()
                >>> lat = group.CreateDimension("lat", None, None, 2)
                >>> lon = group.CreateDimension("lon", None, None, 3)
                >>> grid = group.CreateMDArray(
                ...     "t2m", [lat, lon], gdal.ExtendedDataType.Create(gdal.GDT_Float32)
                ... )
                >>> grid.Write(np.arange(6, dtype="float32").reshape(2, 3))
                0
                >>> Container(store).get_variable("t2m").shape
                (1, 2, 3)

                ```
            - A 1-D variable has no raster plane, so it answers as a
              `LabeledArray`, carrying the labels GDAL declares for it:
                ```python
                >>> import numpy as np
                >>> from osgeo import gdal
                >>> from pyramids.netcdf import Container
                >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
                >>> group = store.GetRootGroup()
                >>> ilev = group.CreateDimension("ilev", None, None, 4)
                >>> coefficients = group.CreateMDArray(
                ...     "hyai", [ilev], gdal.ExtendedDataType.Create(gdal.GDT_Float64)
                ... )
                >>> coefficients.Write(np.array([0.0, 0.25, 0.5, 1.0]))
                0
                >>> coefficients.SetUnit("1")
                0
                >>> long_name = coefficients.CreateAttribute(
                ...     "long_name", [], gdal.ExtendedDataType.CreateString()
                ... )
                >>> long_name.Write("hybrid A coefficient")
                0
                >>> hyai = Container(store).get_variable("hyai")
                >>> hyai.dims, hyai.shape
                (('ilev',), (4,))
                >>> hyai.values.tolist()
                [0.0, 0.25, 0.5, 1.0]
                >>> hyai.name, hyai.unit
                ('hyai', '1')
                >>> hyai.attributes["long_name"]
                'hybrid A coefficient'

                ```
            - A string column is non-numeric at any rank, so it is labelled too
              — including the 2-D `(record, strlen)` shape a driver that models
              the string length as a dimension reports:
                ```python
                >>> from osgeo import gdal
                >>> from pyramids.netcdf import Container
                >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
                >>> group = store.GetRootGroup()
                >>> record = group.CreateDimension("record", None, None, 2)
                >>> strlen = group.CreateDimension("strlen", None, None, 3)
                >>> units = group.CreateMDArray(
                ...     "units", [record, strlen], gdal.ExtendedDataType.CreateString()
                ... )
                >>> station = group.CreateMDArray(
                ...     "station", [record], gdal.ExtendedDataType.CreateString()
                ... )
                >>> station.Write(["brienz", "thun"])
                0
                >>> nc = Container(store)
                >>> nc.get_variable("units").dims
                ('record', 'strlen')
                >>> nc.get_variable("station")
                LabeledArray('station', dims=('record',), shape=(2,))
                >>> nc.get_variable("station").values.tolist()
                ['brienz', 'thun']

                ```

        See Also:
            `sel`: subsets the result along any tracked band dim.
            `pyramids.netcdf.LabeledArray`: what a non-raster variable answers with.
            `pyramids.netcdf.LabeledDataset`: reads a whole label-indexed store the
                same way.
        """
        # Handle group-qualified names: "forecast/temperature"
        if "/" in variable_name:
            return self._get_group_variable(variable_name, x_dim, y_dim)

        # Checked against what the store *holds*, not against `variable_names`:
        # that property enumerates data variables, so CF non-data arrays -- 2-D
        # curvilinear coordinates like `xc` / `lat_rho`, bounds, ancillary
        # fields -- are deliberately absent from it, yet reading one by name is
        # legitimate. Gating on the enumeration made `get_variable("xc")` fail
        # on a curvilinear store while the array sat right there.
        readable = self._readable_variable_names()
        if variable_name not in readable:
            # The list shown is the list the check above consults, not
            # `variable_names`. Printing the narrow enumeration told a user who
            # mistyped `lat_rho` that the store held only `['salt', 'zeta']` --
            # a refusal that names none of the accepted answers.
            raise ValueError(
                f"{variable_name} is not a valid variable name in {readable}"
            )

        prefix = self.driver_type.upper()
        rg = self._working_group()
        md_arr_ref = None
        rg_ref = None

        spatial_dim_indices: tuple[int, int] | None = None
        if prefix == "MEMORY" or rg is not None:
            src, md_arr_ref, rg_ref, x_index, y_index, y_flipped, x_flipped = (
                self._read_md_array(variable_name, x_dim=x_dim, y_dim=y_dim)
            )
            if x_index is not None:
                spatial_dim_indices = (x_index, y_index)
            if isinstance(src, gdal.Dataset):
                # Declared `NetCDF`, not inferred: `_georeference_index_subset`
                # below reassigns `cube` a `NetCDF`, and with the group branch now
                # in its own method this is the first assignment mypy sees.
                cube: NetCDF = Variable(src)
                cube._is_md_array = True
                # Which spatial axes _read_md_array reversed, and where the raster plane sits in the
                # MDArray. The eager materialize path rebuilds the unreversed view from these and
                # re-applies the flips with NumPy (see _materialize_from_raw_view).
                cube._md_y_flipped = y_flipped
                cube._md_x_flipped = x_flipped
                cube._md_spatial_dims = spatial_dim_indices
                # _read_md_array flips the data lazily and GDAL usually corrects the geotransform,
                # but a spatial dim with no indexing variable (e.g. WRF "south_north") can leave it
                # wrong; fix it on the wrapper (no data copy).
                self._correct_flipped_geotransform(cube)
            else:
                # `src` is the MDArray itself -- GDAL could not expose it as a
                # raster plane. Materialise it instead of returning it: a public
                # accessor must never hand back a raw GDAL handle (#1126). The
                # references below are neither needed nor settable for it: the
                # values are materialised into NumPy's own memory rather than a
                # view over GDAL's, so nothing has to be kept alive, and
                # `LabeledArray` defines `__slots__`.
                return _labeled_array_from_md_array(src, variable_name)
            # Keep GDAL SWIG references alive — AsClassicDataset returns a
            # view whose C++ backing is owned by the MDArray/root group.
            # Without these the view becomes a dangling pointer on Windows.
            cube._gdal_md_arr_ref = md_arr_ref
            cube._gdal_rg_ref = rg_ref
        else:
            # Prefer the real /vsimem backing path over a cosmetic from_bytes `name=` that
            # shadows file_name, so a named in-memory classic read reopens the true source (#1057).
            path = getattr(self, "_vsimem_path", None) or self.file_name
            src = gdal.Open(f'{prefix}:"{path}":{variable_name}')
            if src is None:
                raise ValueError(
                    f"Could not open variable '{variable_name}' via "
                    f'\'{prefix}:"{path}":{variable_name}\''
                )
            cube = Variable(src)
            cube._is_md_array = False

        cube._is_subset = True

        # --- RT-4: Track variable origin for round-trip ---
        cube._parent_nc = self
        cube._source_var_name = variable_name
        # A variable subset's pickle recipe reopens the parent file, so it inherits
        # the parent's captured GDAL open options (#1025).
        cube._open_options = self._open_options

        # Geostationary (GOES) scan-angle x/y come through the MDIM read path in
        # radians; rescale them to projected metres so the cube is correctly
        # georeferenced and to_crs/crop work. No-op for every other CRS. The
        # guard this used to carry is gone with the reason for it: a variable
        # with no raster plane returns before reaching here.
        cube._normalize_geostationary_geotransform()

        self._attach_variable_metadata(
            cube, md_arr_ref if rg is not None else None, spatial_dim_indices
        )
        cube = self._georeference_index_subset(cube)
        # Take the band dimensions' time units now, while this container is open, so the variable,
        # and anything derived from it, still decodes its stamps (a date `sel`, a frequency
        # `reduce`) after the container is closed.
        cube._band_dim_time_attrs = cube._resolved_band_dim_time_attrs()
        # A container rebuilt by an operation knows the grid it produced; a variable of it holds
        # no coordinate arrays, so it would otherwise fall back to the index space of the view.
        if self._stamped_geotransform is not None:
            cube._stamped_geotransform = self._stamped_geotransform
            cube._geotransform = self._stamped_geotransform
            cube._derived_geotransform = self._stamped_geotransform
            cube._cell_size = GeoTransform(*self._stamped_geotransform).cell_size
        # Record the raster built from the store, after every step above that may replace it, so a
        # streamed read is used only while this variable still reads as its store.
        cube._store_raster = cube._raster
        return cube

    @staticmethod
    def _correct_flipped_geotransform(cube: NetCDF) -> None:
        """Re-anchor the wrapper geotransform for whichever spatial axis was reversed.

        A no-op unless the data was actually reversed (`cube._md_y_flipped` / `cube._md_x_flipped`)
        **and** the geotransform still describes the pre-flip order (`gt[5] > 0` for a Y flip,
        `gt[1] < 0` for an X flip). Used after a lazy `GetView` flip when the dimension has no
        indexing variable, so GDAL could not correct the geotransform itself.

        The flip guards matter because the geotransform sign alone no longer implies a flip: GDAL
        builds the view's geotransform from the *raw* coordinate values, so a negative
        `scale_factor` (geostationary radian scan angles) yields `gt[5] > 0` for an array that was
        left north-up. Flipping the wrapper geotransform there would desync it from the data.
        """
        gt = cube._geotransform
        if cube._md_y_flipped and gt[5] > 0:
            gt = (gt[0], gt[1], gt[2], gt[3] + gt[5] * cube._rows, gt[4], -gt[5])
        if cube._md_x_flipped and gt[1] < 0:
            gt = (gt[0] + gt[1] * cube._columns, -gt[1], gt[2], gt[3], gt[4], gt[5])
        if gt != cube._geotransform:
            cube._derived_geotransform = None
            cube._geotransform = gt
            cube._cell_size = GeoTransform(*gt).cell_size

    def _georeference_index_subset(self, cube: NetCDF) -> NetCDF:
        """Re-georeference a variable subset whose MDArray view came back in index space.

        A subset built from a bare MDArray view can carry an index-space geotransform (cell
        size 1, origin 0) even though the file has real 1-D lon/lat coordinate variables. Those
        coordinates aren't reachable from the subset itself (it has no root group and an empty
        ``file_name``), but they are on this parent container. When they match the subset's grid
        shape and disagree with the view's geotransform, wrap the view in a VRT carrying the
        coordinate-derived geotransform, so warp-based operations (``to_crs`` / ``crop`` /
        ``wrap_longitude``) and basemaps use real degrees instead of pixel indices. The view's
        geotransform is immutable (``SetGeoTransform`` is a no-op on it), hence the VRT wrapper.

        A no-op when: the cube isn't a variable subset; the view is already georeferenced (the
        common case — the derived geotransform matches); the file has no coordinate evidence to
        georeference the grid at all; or the CRS is geostationary. A curvilinear variable (2-D
        lon/lat, no 1-D coordinate) is **not** a no-op here — it is re-georeferenced to a
        bounding-box affine over its 2-D coordinates rather than left in index space (#1039).
        In the geostationary case the 1-D ``x`` / ``y`` are **scan angles** (radians,
        packed with a ``scale_factor``), not projected coordinates, so a coordinate-derived
        geotransform is meaningless — it would overwrite the projected metre grid that
        :meth:`_normalize_geostationary_geotransform` just installed with a raw index-space one.
        The coordinates are read from the parent rather than via ``cube.lon`` / ``cube.lat`` so
        those accessors keep their existing geotransform-derived (north-up) orientation.
        """
        if isinstance(cube, NetCDF) and not cube._is_geostationary():
            real_gt = self._coordinate_derived_geotransform(cube)
            if real_gt is not None:
                # Building the VRT issues a partial-window read of the AsClassicDataset MDArray view,
                # which GDAL >= 3.13 rejects with a spurious `arrayStartIdx[...] >= <dim>` CE_Failure
                # (the same view limitation `_materialize_md_view` works around). The VRT is still
                # produced and correctly georeferenced, so silence that one known-harmless error
                # rather than forcing an eager full read here; a genuine failure still yields
                # `vrt is None` and skips the correction. See issue #628.
                with gdal.quiet_errors():
                    vrt = gdal.Translate("", cube._raster, format="VRT")
                if vrt is not None:
                    vrt.SetGeoTransform(list(real_gt))
                    if cube.epsg:
                        vrt.SetProjection(sr_from_epsg(int(cube.epsg)).ExportToWkt())
                    # The VRT reads through the MDArray view, so keep that view alive.
                    cube._view_source = cube._raster
                    cube._raster = vrt
                    cube._derived_geotransform = None
                    cube._geotransform = real_gt
                    cube._cell_size = GeoTransform(*real_gt).cell_size
        return cube

    def _first_coordinate(self, candidates: tuple[str, ...]) -> tuple[Any, str | None]:
        """The first readable coordinate variable among `candidates`, with the name it was found under.

        Strictly first-match: rank is **not** a tiebreak. Preferring a 1-D array here would look
        like a harmless way to skip a 2-D CF bounds array, but it also promotes a 1-D index axis
        (``x``/``y``) over the 2-D ``lon``/``lat`` of a curvilinear file — and the caller relies on
        getting that 2-D array back to route into :meth:`_curvilinear_bbox_geotransform`, so the
        grid would keep an index-space affine instead of its real extent (#1039). Bounds arrays are
        excluded from the candidate list itself, by :meth:`_bounds_array_names`, which is where the
        distinction can actually be drawn.

        Args:
            candidates: Ordered names to try, from :meth:`_coordinate_candidates`.

        Returns:
            tuple[Any, str | None]: The coordinate values and the name they were found under, or
            ``(None, None)`` when nothing readable matched.
        """
        values, found = None, None
        # These are probes: most candidates are absent, and in classic mode each miss is a real
        # `gdal.Open("NETCDF:<file>:<name>")` that fails and pushes a GDAL error out to the user as
        # a `RuntimeWarning` ("... is not a variable"). The misses are expected and handled — a
        # name that does not read is simply the next one's turn — so quiet them here rather than
        # let a routine lookup spray warnings the caller can do nothing about. Silencing the noise
        # is the right lever; skipping the probes is not, because a readable coordinate is not
        # always an enumerable one.
        with gdal.quiet_errors():
            for name in candidates:
                array = self._read_variable(name)
                if array is not None:
                    values, found = np.asarray(array), name
                    break
        return values, found

    def _bounds_array_names(self, rg: Any, present: list[str]) -> set[str]:
        """Names in `present` that are CF *bounds* arrays rather than axis coordinates.

        CF says a bounds variable inherits its parent's ``units`` and ``standard_name``, so
        ``latitude_bnds`` is indistinguishable from ``latitude`` by attributes alone and would
        otherwise enter the candidate list and shadow the real coordinate (#1071 review).

        Two signals, in order of authority: the ``bounds`` attribute a coordinate uses to name its
        own bounds array — the CF-sanctioned link — and, for files that omit it, the conventional
        ``_bnds`` / ``_bounds`` / ``_vertices`` suffixes.

        Args:
            rg: The store's multidimensional root group.
            present: Array names in the working group.

        Returns:
            set[str]: Names to exclude from the axis-coordinate candidates.
        """
        declared: set[str] = set()
        for name in present:
            array = open_mdarray(rg, name)
            if array is None:
                continue
            target = _read_attributes(array).get("bounds")
            if isinstance(target, str) and target:
                declared.add(target)
        conventional = {
            name
            for name in present
            if name.lower().endswith(("_bnds", "_bounds", "_vertices"))
        }
        return declared | conventional

    def _present_coordinate_names(self) -> tuple[gdal.Group | None, list[str]]:
        """The working group and the array names it enumerates, CF bounds arrays removed.

        Multidimensional mode lists them off the working group; classic (non-MDIM) mode has no
        group, so the subdataset list stands in.

        This is what the store *enumerates*, which is narrower than what it can *read*: a
        coordinate at the root is readable from a subgroup view without appearing here, and GDAL's
        classic subdataset list holds only the >= 2-D data variables. Callers must therefore treat
        an absent name as "not advertised", never as "not there" — see the tail in
        :meth:`_coordinate_candidates`.

        Returns:
            tuple[gdal.Group | None, list[str]]: The working group (``None`` in classic mode) and
            the enumerated names, with CF bounds arrays excluded.
        """
        rg = self._working_group()
        if rg is None:
            return None, self._classic_subdataset_variable_names()
        present = list(rg.GetMDArrayNames() or [])
        bounds = self._bounds_array_names(rg, present)
        return rg, [name for name in present if name not in bounds]

    def _coordinate_candidates(self, axis: str) -> tuple[str, ...]:
        """Ordered coordinate-variable names to try when georeferencing `axis` (``"X"`` / ``"Y"``).

        The order is deliberate, and `_first_coordinate` takes the first that reads:

        1. the two legacy names (``lon``/``x``, ``lat``/``y``) — kept first so a file that already
           resolved keeps resolving to exactly the same coordinate;
        2. names the file's own CF attributes identify as this axis (:meth:`_axis_role`), which is
           the principled signal and catches a coordinate under any name;
        3. the remaining well-known names (:data:`_X_DIM_NAMES` / :data:`_Y_DIM_NAMES`) matched
           **case-insensitively against the names actually present**, so ``Latitude`` or ``LON``
           resolve without another hardcoded spelling.

        Stages 2 and 3 are why a CF-standard ``latitude`` / ``longitude`` grid is georeferenced at
        all: the legacy pair never named them, so a variable whose driver does not supply an affine
        (the HDF5 driver, which serves any ``/vsi`` NetCDF-4 read on Windows) was left in index
        space (#1071). A frozenset is never iterated directly here — its order is not defined, and
        this lookup is first-match.

        Cached per axis on the container. Stage 2 opens **every** array in the group to read its
        attributes, and the rescue asks for both axes of every variable it georeferences — over
        ``/vsicurl`` that is a round trip per array per variable, for an answer that depends only
        on the container's own layout and cannot change while it is open.

        Args:
            axis: ``"X"`` for the longitude/easting axis, ``"Y"`` for latitude/northing.

        Returns:
            tuple[str, ...]: Candidate names, most-specific first, without duplicates.

        Raises:
            KeyError: `axis` is neither ``"X"`` nor ``"Y"``. Deliberate: the earlier
                ``if axis == "X" else`` form silently treated any typo as the Y axis and returned
                latitude names for it.
        """
        cache: dict[str, tuple[str, ...]] = self.__dict__.setdefault(
            "_coordinate_candidate_cache", {}
        )
        if axis not in cache:
            legacy, well_known, preferred = _AXIS_NAME_SOURCES[axis]
            rg, present = self._present_coordinate_names()
            cf_named = (
                [name for name in present if self._axis_role(rg, name) == axis]
                if rg is not None
                else []
            )
            well_known_present = [
                name for name in present if name.lower() in well_known
            ]
            # The well-known tail is always offered, never trimmed against `present`. "Not
            # enumerated" does not mean "not readable": the resolver reaches further than the
            # enumerator — `_read_indexing_variable` finds a coordinate that lives in the *root*
            # group from a subgroup view, and GDAL's classic subdataset list holds only the >= 2-D
            # data variables, never the 1-D coordinates, which `NETCDF:"<file>":latitude` opens
            # perfectly well. Trimming the tail therefore silently un-georeferenced group-scoped
            # and classic-mode stores. It costs nothing on a file that resolves anyway, because
            # the tail is last and the lookup is first-match; the names below it are only ever
            # tried when everything else has already missed.
            ordered: list[str] = []
            for name in (*legacy, *cf_named, *well_known_present, *preferred):
                if name not in ordered:
                    ordered.append(name)
            cache[axis] = tuple(ordered)
        return cache[axis]

    @staticmethod
    def _coordinates_index_subset(cube: NetCDF, lon, lat, lon_name: str | None) -> bool:
        """Whether the parent's 1-D lon/lat legitimately index `cube`'s spatial grid.

        Adopt them only when the variable actually has the longitude coordinate dimension (by the CF
        coordinate-variable convention a 1-D coord var shares its dimension's name). Test membership,
        not position, so it holds when x_dim/y_dim select a non-trailing plane (e.g.
        ``T(time, lat, lev, lon)``). Only the X (longitude) dim is checked: a Y-flip in
        ``_read_md_array`` renames the latitude dimension (e.g. ``subset_lat_…``), so the lat name is
        not reliably present. This still guards a same-shaped but unrelated axis (one with no
        longitude dimension) from adopting the wrong coordinates.

        An X-flip renames the longitude dimension the same way (``subset_lon_4_-1_5``), so accept
        that form too — but only when this cube actually *was* X-flipped. The name alone is a
        coincidence a real on-disk dimension could reproduce; paired with the recorded flip it is
        evidence that GDAL, not the file's author, wrote it.
        """
        dim_names = getattr(cube, "_md_array_dims", None) or []
        renamed_prefix = f"subset_{lon_name}_" if lon_name else None
        gdal_renamed_x = bool(cube._md_x_flipped) and any(
            renamed_prefix is not None and name.startswith(renamed_prefix)
            for name in dim_names
        )
        names_ok = (not dim_names) or (lon_name in dim_names) or gdal_renamed_x
        return bool(
            lon is not None
            and lat is not None
            and lon.ndim == 1
            and lat.ndim == 1
            and len(lon) == cube.columns
            and len(lat) == cube.rows
            and len(lon) >= 2
            and len(lat) >= 2
            and names_ok
        )

    def _coordinate_derived_geotransform(self, cube: NetCDF) -> tuple | None:
        """Real-world geotransform from the parent's lon/lat coordinates, or ``None``.

        Returns the north-up affine implied by the parent container's 1-D longitude/latitude
        coordinate variables — located by :meth:`_coordinate_candidates`, so CF-standard
        ``latitude``/``longitude`` and the other well-known spellings count, not just ``lon``/``lat``
        or ``x``/``y`` (#1071) — when they (a) match the subset's grid shape, (b) index the
        subset's spatial dimensions by name (CF coordinate-variable convention — guards a same-shaped
        but different staggered/rotated axis from adopting the wrong coordinates), and (c) actually
        differ from the subset's current (index-space) geotransform. When no 1-D coordinate indexes
        the grid — a curvilinear variable whose lon/lat are 2-D — falls back to a bounding-box affine
        over those 2-D coordinates (:meth:`_curvilinear_bbox_geotransform`, #1039). Otherwise ``None``.
        """
        result = None
        lon, lon_name = self._first_coordinate(self._coordinate_candidates("X"))
        lat, _ = self._first_coordinate(self._coordinate_candidates("Y"))
        if self._coordinates_index_subset(cube, lon, lat, lon_name):
            # Anchor the affine on the coordinate that the *array's* first column / row actually
            # sits at. `_read_md_array` reverses an axis it decided was backwards, so after a flip
            # col 0 holds the last stored longitude, and without one it holds the first. Taking
            # min(lon) / max(lat) instead would assume the array was always reversed from these
            # coordinates -- untrue when the flip came from the geotransform-sign fallback (an
            # unreadable, constant or non-finite coordinate), leaving the affine describing a
            # mirror of the array it georeferences.
            x_cell = abs(float(lon[1] - lon[0]))
            y_cell = abs(float(lat[1] - lat[0]))
            west_centre = float(lon[-1] if cube._md_x_flipped else lon[0])
            north_centre = float(lat[-1] if cube._md_y_flipped else lat[0])
            real_gt = (
                west_centre - x_cell / 2,
                x_cell,
                0.0,
                north_centre + y_cell / 2,
                0.0,
                -y_cell,
            )
            current = cube._raster.GetGeoTransform()
            if not all(
                abs(float(a) - float(b)) < 1e-6 for a, b in zip(real_gt, current)
            ):
                result = real_gt
        else:
            # No usable 1-D coordinate indexes the grid: a curvilinear variable carries its
            # lon/lat as 2-D arrays. Derive a north-up bounding-box affine from those so it is
            # georeferenced to its real geographic extent rather than the index-space
            # placeholder GDAL falls back to for a grid it cannot express affinely (#1039).
            result = self._curvilinear_bbox_geotransform(cube)
        return result

    def _curvilinear_bbox_geotransform(self, cube: NetCDF) -> tuple | None:
        """North-up bounding-box affine from a variable's 2-D curvilinear lon/lat.

        A curvilinear grid stores longitude/latitude as 2-D arrays (e.g. ROMS
        ``lon_rho``/``lat_rho``, RASM ``xc``/``yc``) and has no single affine
        geotransform, so GDAL's classic driver falls back to an index-space one. Reporting
        that tagged with the grid's real CRS is a fabricated georeference that misplaces the
        data (#1039). Derive instead a north-up affine spanning the real lon/lat bounding
        box, so the variable is georeferenced to its true geographic extent. This is an
        approximation of the curved grid — for a non-wrapping grid the bounding box is placed
        correctly, individual cells only approximately — but it reflects the data's real
        location instead of pixel indices, and pairs honestly with the WGS 84 CRS the
        coordinates already imply. A grid that *narrowly* straddles the antimeridian — its
        longitudes leave a >180° gap, so raw min/max would span nearly the whole globe —
        declines instead. A *wide* dateline-crossing grid (>180° of coverage, hence no such
        gap) cannot be told apart from a legitimate wide grid by coordinates alone, so it is
        approximated like any other and may be misplaced in longitude; its exact answer is the
        GEOLOCATION domain (#1033).

        Args:
            cube: The variable subset whose 2-D coordinates georeference the grid.

        Returns:
            tuple | None: The north-up bounding-box affine
            ``(x_min - x_cell/2, x_cell, 0, y_max + y_cell/2, 0, -y_cell)`` — the origin is the
            north-west pixel *edge*, half a cell out from the extreme coordinate centres. Or
            ``None`` when the CRS is not geographic, the variable is not curvilinear, its 2-D
            coordinates are unreadable / mis-shaped / degenerate / out of geographic range, or
            the grid crosses the antimeridian — leaving the caller's fallback in place.
        """
        # A degrees bounding box is only meaningful under a geographic CRS. A projected or
        # rotated grid whose 2-D aux coords are lon/lat would otherwise get a degrees affine
        # stamped under its metre CRS; this also skips the resolver read for a variable that
        # carries no geographic CRS at all (round-1 review L1/N3).
        crs = cube.crs
        if not crs or not sr_from_wkt(crs).IsGeographic():
            return None
        with warnings.catch_warnings():
            # The georeference path legitimately uses the resolver's model-name coordinate
            # fallback; its plot-side "set the CF coordinates attribute / pass coords="
            # DeprecationWarning does not apply here (there is no coords= on the read path), so
            # silence that one warning for this internal resolution rather than surface it on
            # every load of a non-CF-compliant curvilinear variable (round-2 review M1).
            warnings.filterwarnings("ignore", category=DeprecationWarning)
            curv = NetCDFPlot(cube)._resolve_curvilinear_coords(cube, coords=None)
        if curv is None:
            return None
        lon2d = np.asarray(curv[0], dtype="f8")
        lat2d = np.asarray(curv[1], dtype="f8")
        expected = (cube.rows, cube.columns)
        if (
            lon2d.ndim != 2
            or lat2d.ndim != 2
            or lon2d.shape != expected
            or lat2d.shape != expected
            or cube.rows < 2
            or cube.columns < 2
            or not np.isfinite(lon2d).any()
            or not np.isfinite(lat2d).any()
        ):
            return None
        x_min, x_max = float(np.nanmin(lon2d)), float(np.nanmax(lon2d))
        y_min, y_max = float(np.nanmin(lat2d)), float(np.nanmax(lat2d))
        # Masked cells can carry a large finite _FillValue sentinel (ReadAsArray is raw), which
        # np.nanmax would pick up. Reject a box outside plausible geographic bounds rather than
        # trust a fabricated extent (round-1 review L2).
        if (
            not np.isfinite([x_min, x_max, y_min, y_max]).all()
            or x_max <= x_min
            or y_max <= y_min
            or x_min < -360.0
            or x_max > 360.0
            or y_min < -90.0
            or y_max > 90.0
        ):
            return None
        # A grid that narrowly straddles the antimeridian stores longitudes clustered at both
        # ends with a wide empty gap between, so raw min/max span nearly the whole globe and
        # misplace the domain in x. Decline (leave the index-space placeholder) rather than
        # fabricate a globe-spanning affine. A contiguous grid — including a genuinely
        # circumpolar one whose longitudes fill the circle without a gap — has no such gap and
        # is kept. This gap test cannot catch a *wide* dateline crossing (>180° coverage, gap
        # <180°) without also falsely declining a polar grid whose meridians converge, so that
        # rarer case is approximated rather than declined (#1039; GEOLOCATION is #1033). The
        # cheap span pre-check keeps the sort off the common narrow-domain path.
        if (x_max - x_min) > 180.0:
            finite_lon = np.sort(lon2d[np.isfinite(lon2d)].ravel())
            if finite_lon.size >= 2 and float(np.max(np.diff(finite_lon))) > 180.0:
                return None
        x_cell = (x_max - x_min) / (cube.columns - 1)
        y_cell = (y_max - y_min) / (cube.rows - 1)
        return (x_min - x_cell / 2, x_cell, 0.0, y_max + y_cell / 2, 0.0, -y_cell)

    def _attach_variable_metadata(
        self, cube: NetCDF, md_arr, spatial_dim_indices: tuple[int, int] | None
    ) -> None:
        """Populate band-dim tracking, variable attributes, and packing on a variable subset.

        When `md_arr` is `None` (e.g. the file-backed gdal.Open path) the band/attr metadata is
        cleared to empty defaults. `cube` is always a `NetCDF`: a variable with no raster plane
        — a 1-D one, or a non-numeric one of any rank (see `_read_md_array`) — is materialised
        into a `LabeledArray` and returned by `get_variable` before this is called, so the band
        model `_track_band_dimensions` and `_apply_attribute_no_data` read is always present.
        `_copy_variable_attrs` never depended on it either way — it reads the MDArray, not the
        cube.
        """
        if md_arr is None:
            self._clear_variable_metadata(cube)
            return
        dims = md_arr.GetDimensions()
        cube._md_array_dims = [d.GetName() for d in dims]
        self._track_band_dimensions(cube, dims, spatial_dim_indices)
        self._copy_variable_attrs(cube, md_arr)
        self._apply_attribute_no_data(cube, md_arr)

    @staticmethod
    def _apply_attribute_no_data(cube: NetCDF, md_arr: Any) -> None:
        """Stamp an attribute-derived no-data onto a variable whose classic view has none.

        A variable's ``AsClassicDataset`` view only sometimes exposes a band no-data — GDAL
        surfaces some ``_FillValue`` types but not all, and never the ``nodata`` attribute
        :meth:`DatasetCollection.to_netcdf` writes (GDAL's multidim writer rejects ``_FillValue``).
        When every band reports ``None``, resolve the value from the MDArray's CF/``nodata``
        attributes and record it on the Python-side per-band list that :attr:`no_data_value` and the
        plot mask read. The view ignores ``SetNoDataValue``, so only the wrapper list is updated.

        A variable GDAL cannot expose as a raster — a 1-D one, or a non-numeric one of any rank
        (see :meth:`_read_md_array`) — never reaches here: :meth:`get_variable` materialises it
        into a :class:`~pyramids.netcdf.LabeledArray` and returns before this is called.
        """
        current = getattr(cube, "_no_data_value", None)
        if current is not None and not any(v is not None for v in current):
            ndv = NetCDF._md_array_no_data(md_arr)
            if ndv is not None:
                cube._no_data_value = [ndv] * cube._band_count

    @staticmethod
    def _clear_variable_metadata(cube: NetCDF) -> None:
        """Reset a subset's band-dimension, attribute, and packing metadata to empty defaults."""
        cube._md_array_dims = []
        cube._band_dim_name = None
        cube._band_dim_values = None
        cube._band_dim_names = ()
        cube._band_dim_values_map = {}
        cube._band_dim_sizes = ()
        cube._variable_attrs = {}
        cube._scale = None
        cube._offset = None

    @staticmethod
    def _track_band_dimensions(
        cube: NetCDF, dims, spatial_dim_indices: tuple[int, int] | None
    ) -> None:
        """Map every non-spatial dimension onto bands so `sel()` can address 4-D+ variables.

        The spatial (X/Y) dimensions are taken from `spatial_dim_indices` (resolved on the unflipped
        array) when available, else the last two. The legacy `_band_dim_name`/`_band_dim_values`
        fields point at the first non-spatial dim so existing 3-D consumers are unaffected.

        `cube` is always a raster-backed `NetCDF` (see `_read_md_array`): an array with no raster
        plane is materialised by `get_variable` and never reaches here. A rank <= 2 variable takes
        the empty-`band_dims` exit below, which clears all five fields; a rank >= 3 one gets the
        per-dimension `_band_dim_names` / `_band_dim_sizes` / `_band_dim_values_map` tracking, and
        its legacy pair is derived from `cube._band_count`. That derivation is why the raw
        `gdal.MDArray` this used to be handed had to be special-cased: it carries no band model, so
        it has no `_band_count` to derive from, and the legacy pair was left `None` (#1067).
        """
        if len(dims) > 2:
            spatial = (
                set(spatial_dim_indices)
                if spatial_dim_indices is not None
                else {len(dims) - 1, len(dims) - 2}
            )
            band_dims = [d for i, d in enumerate(dims) if i not in spatial]
        else:
            band_dims = []

        if not band_dims:
            cube._band_dim_name = None
            cube._band_dim_values = None
            cube._band_dim_names = ()
            cube._band_dim_values_map = {}
            cube._band_dim_sizes = ()
            return

        cube._band_dim_names = tuple(d.GetName() for d in band_dims)
        cube._band_dim_sizes = tuple(d.GetSize() for d in band_dims)
        cube._band_dim_values_map = {
            d.GetName(): NetCDF._read_band_dim_values(d) for d in band_dims
        }
        cube._band_dim_name, cube._band_dim_values = NetCDF._derive_primary_band_view(
            cube._band_dim_names,
            cube._band_dim_values_map,
            cube._band_dim_sizes,
            cube._band_count,
        )

    @staticmethod
    def _read_band_dim_values(dim):
        """Indexing-variable values for a band dimension, or integer indices when unreadable.

        String-typed indexing variables (e.g. WRF `Times`) cannot be read via `ReadAsArray` in the
        GDAL SWIG bindings; the list-based `Read()` path handles those, and is the one the container
        accessor and `to_xarray` use, so a subset reports the same coordinates as its container.
        `[0, 1, ..., size - 1]` remains only when that read fails too.
        """
        indexing_var = dim.GetIndexingVariable()
        if indexing_var is None:
            return None
        try:
            values = indexing_var.ReadAsArray().tolist()
        except RuntimeError:
            try:
                values = NetCDF._md_array_to_numpy(indexing_var).tolist()
            except Exception:
                values = list(range(dim.GetSize()))
        return values

    @staticmethod
    def _copy_variable_attrs(cube: NetCDF, md_arr) -> None:
        """Copy the variable's attributes and CF `scale`/`offset` packing onto the subset."""
        cube._variable_attrs = _read_attributes(md_arr)
        try:
            cube._scale = md_arr.GetScale()
            cube._offset = md_arr.GetOffset()
        except (RuntimeError, AttributeError):
            cube._scale = None
            cube._offset = None

    def _with_coordinate_refs(
        self,
        receivers: Sequence[str],
        *,
        add: tuple[str, ...] = (),
        remove: tuple[str, ...] = (),
    ) -> NetCDF:
        """Rewrite the CF `coordinates` attribute of `receivers`, on a copy of the store.

        The write side of the auxiliary-coordinate role pyramids already reads: CF says a
        variable is a coordinate of another by naming it in that other's `coordinates`
        attribute, `cf.classify_variables` parses exactly that, and `variable_names`
        filters itself by the roles it reports. So moving a name in or out of this
        attribute is what promotes or demotes it — nothing else has to be taught the
        partition.

        Non-mutating, like the rest of the structural members: `_writable_root_group`
        hands back an independent in-memory copy of the store, the attributes are rewritten
        on that, and the copy is wrapped as a new cube. The receiver this was called on is
        untouched.

        The root container is the only receiver that reaches here: both callers run
        `_assert_coordinate_partition` first, which refuses a `get_group` view because a CF
        reference is a relative name while a sub-group's arrays classify under
        group-qualified ones. So there is no view to re-derive on the way out.

        Each receiver keeps the references it already had: a name is appended only when it
        is not already there (so a repeated promotion is idempotent) and removed only when
        it is, and the attribute is deleted outright rather than left as an empty string
        when nothing is left to reference.

        Args:
            receivers: The data variables whose `coordinates` attribute to rewrite.
            add: Names to reference.
            remove: Names to stop referencing.

        Returns:
            NetCDF: A new cube whose store carries the rewritten references.
        """
        dataset, group = self._writable_root_group()
        for name in receivers:
            array = group.OpenMDArray(name)
            current = str(_read_attributes(array).get("coordinates") or "").split()
            kept = [ref for ref in current if ref not in remove]
            updated = [*kept, *[ref for ref in add if ref not in kept]]
            if updated != current:
                NetCDF._write_coordinate_refs(array, updated)
        return Container(dataset)

    @staticmethod
    def _write_coordinate_refs(array: gdal.MDArray, refs: list[str]) -> None:
        """Put `refs` in `array`'s CF `coordinates` attribute, or take the attribute away.

        The existing attribute is **deleted before** the new one is created. `CreateAttribute`
        refuses a name the array already carries, and `write_attributes_to_md_array` logs and
        skips a refusal rather than raising — so writing over an existing `coordinates` was a
        silent no-op on the MEM driver (the netCDF driver happens to allow it). That broke
        exactly the cases where the attribute is edited rather than created: demoting one of
        several coordinates, and promoting a second one.

        An empty reference list is written as *no attribute* rather than as an empty string:
        `coordinates = ""` would still declare the attribute, and a store that has nothing to
        say about coordinates should not say it.

        Args:
            array: The data variable to write on.
            refs: The coordinate variable names it should reference.
        """
        if "coordinates" in {attr.GetName() for attr in array.GetAttributes()}:
            array.DeleteAttribute("coordinates")
        if refs:
            write_attributes_to_md_array(array, {"coordinates": " ".join(refs)})

    def _writable_root_group(self) -> tuple[gdal.Dataset, gdal.Group]:
        """Return a ``(dataset, working_group)`` pair that is safe to mutate.

        Always returns an **independent** in-memory ``MEM`` copy of the backing store,
        never ``self._raster`` itself. Two reasons: a file-backed netCDF root group is
        opened in "data mode", which rejects ``CreateMDArray`` / ``DeleteMDArray`` /
        ``CreateDimension``; and mutating an in-memory container in place would corrupt
        every other handle that shares the same ``gdal.Dataset`` — a ``get_group()`` view
        (a zero-copy view of its parent's raster) or a caller holding a reference to
        ``_raster`` (#143). The caller mutates the returned copy and swaps it in via
        :meth:`_replace_raster`, so external handles keep the pre-mutation state.

        For a ``get_group()`` view (``_group_path`` set) the returned group is the
        **sub-group** inside the writable copy, not its root — so
        ``set_variable`` / ``add_variable`` / ``rename_variable`` mutate the group
        the view reads from rather than the store root (ARC-12). ``_group_path`` is
        preserved across :meth:`_replace_raster`, so reads stay consistent with the
        write.

        Returns:
            tuple: The writable :class:`osgeo.gdal.Dataset` and its working group.
        """
        dst = gdal.GetDriverByName("MEM").CreateCopy("", self._raster, 0)
        group = dst.GetRootGroup()
        if group is not None and self._group_path:
            for part in self._group_path.split("/"):
                group = group.OpenGroup(part)
                if group is None:
                    break
        return dst, group

    @staticmethod
    def _derive_primary_band_view(
        names: tuple[str, ...],
        values_map: dict[str, list[Any] | None],
        sizes: tuple[int, ...],
        band_count: int,
    ) -> tuple[str | None, list[Any] | None]:
        """Derive the legacy ``(_band_dim_name, _band_dim_values)`` view from the canonical fields.

        The legacy single-band-dim pair is a *view* of the canonical multi-band-dim
        state: the name is the first (primary) band dimension and the values are that
        dim's entry in ``values_map``. This staticmethod is the single source of truth
        for that derivation — it replaces the per-call-site reconciliation that the
        wrap/build/``sel`` paths used to repeat. The primary coordinate values are
        exposed only when they are provably current for ``band_count``; otherwise they
        come back ``None`` because a band-shrinking operation left the cached primary
        view stale.

        Args:
            names: Canonical band-dimension names (``_band_dim_names``); the first is
                the primary dim the legacy view points at.
            values_map: Per-dim coordinate values (``_band_dim_values_map``).
            sizes: Per-dim sizes (``_band_dim_sizes``); their product is the expected
                band count for a multi-band-dim variable.
            band_count: The live band count of the backing raster.

        Returns:
            tuple: ``(name, values)`` for the primary band dim. ``name`` is ``None``
            when there is no band dimension; ``values`` is ``None`` when there are no
            coordinates or the cached primary view is stale for ``band_count``.
        """
        name = names[0] if names else None
        if name is None:
            return None, None
        values = values_map.get(name)
        if values is None or band_count <= 0:
            return name, values
        if len(names) > 1:
            # Multi-band-dim: the primary view is valid iff the product of the cached
            # sizes still equals the live band count (e.g. a band-shrinking op outside
            # ``sel()`` would diverge them, making the cached primary view stale).
            return name, (values if math.prod(sizes) == band_count else None)
        # Single-band-dim: valid iff the values' own length matches the band count.
        return name, (values if len(values) == band_count else None)

    @staticmethod
    def _copy_band_dim_metadata(dst: Any, src: Any) -> None:
        """Copy the band-dimension bookkeeping from `src` onto `dst`.

        Carries the multi-band-dim fields (`_band_dim_names` / `_band_dim_values_map`
        / `_band_dim_sizes`) and re-derives the legacy single-band-dim view
        (`_band_dim_name` / `_band_dim_values`) from them via
        :meth:`_derive_primary_band_view`, so a derived view keeps the same non-spatial
        axis layout. The values map is copied together with its coordinate lists
        (`copy_band_values_map`), so editing one object's stamps leaves the other's alone, and
        `src`'s band-dimension units are resolved into `dst._band_dim_time_attrs`, so `dst`
        still decodes its stamps without a store of its own.

        Args:
            dst: The object to label, such as an operator result.
            src: The object whose band layout describes `dst`.
        """
        dst._band_dim_names = src._band_dim_names
        dst._band_dim_values_map = copy_band_values_map(src._band_dim_values_map)
        dst._band_dim_sizes = src._band_dim_sizes
        dst._band_dim_time_attrs = src._resolved_band_dim_time_attrs()
        dst._band_dim_name, dst._band_dim_values = NetCDF._derive_primary_band_view(
            dst._band_dim_names,
            dst._band_dim_values_map,
            dst._band_dim_sizes,
            dst._band_count,
        )

    def combine(
        self,
        other: Dataset,
        func: Callable[[np.ndarray, np.ndarray], np.ndarray],
        *,
        band: int | None = None,
        no_data_value: Any = _DERIVE_NO_DATA,
    ) -> Dataset:
        """Combine two rasters cell by cell, keeping the operands' band dimensions.

        Delegates to `Dataset.combine`. Its engine, `Analysis._combine`, checks the band
        layouts through `_combine_layout_source` once the grids and band counts are known to
        agree and before any pixel is read, then labels the result with the band dimensions —
        names, sizes and coordinates — through `_label_combined`, so a combined
        `(time, pressure_level)` variable can still be passed to `sel`, `isel` or anything else
        that reads that layout. The operators `+`, `-`, `*`, `/`, `<`, `<=`, `>` and `>=`
        between two variables reach this method, so they keep the labels too; `==` and `!=`
        stay Python's identity comparison and never get here. A scalar operand (`var * 2`,
        `2 - var`) reaches the same engine without passing through here, and keeps them as well.

        The labels come from this variable when it has them, and from `other` when only it
        does. What one operand lacks, the other supplies, so no label is lost to the order the
        operands are written in: a dimension without coordinates on one side takes the other
        side's coordinates with their time units, and agreeing coordinates without time units
        take the ones the other side has. Where both sides have agreeing coordinates, this
        variable's are kept, in its own units: hours `[0, 6]` plus days `[0, 0.25]` since one
        origin comes back as hours `[0, 6]`, the reverse as days `[0, 0.25]`, the same instants.

        When both carry band dimensions, their names and sizes must match, or the planes do not
        pair up and the call is refused. Their coordinate values may differ: two cuts of one
        variable at different steps (`sel(time=6.0) - sel(time=0.0)`, a tendency
        `isel(time=slice(1, None)) - isel(time=slice(None, -1))`) combine, and a dimension
        whose coordinates disagree keeps its name and length but comes back without
        coordinates, since neither operand's stamps describe the result. `isel` still reaches
        it; `sel` by value on that dimension raises `ValueError`. Where both operands carry CF
        time units for a dimension and the units differ, the stamps are compared as decoded
        time instants, to the microsecond — hours `[0, 6]` and days `[0, 0.25]` since the same
        origin are one axis, while milliseconds `[0, 400]` and seconds `[0, 0.9]` are not. Only CF
        time units count: levels in `millibar` against levels in `hPa` compare their raw values,
        as every other case does. A coordinate-less dimension on either side is not compared.
        The check runs only when `band` is `None` and the two grids and band counts agree.

        A **broadcast** pair is the exception to all of the above: when one operand carries a
        single band it applies to every band of the other, so the layouts are not compared at
        all. The multi-band operand owns the result's dimensions, names, sizes and coordinates,
        whichever side it is on, and the one-band operand lends nothing — its single stamp
        cannot describe the result's planes. `cube * mask` and `mask * cube` therefore come
        back with the same layout.

        Two consequences of that rule are worth stating, because both used to be a
        band-count `ValueError` and now succeed:

        - A band dimension only the **one-band** operand has is **dropped**:
          `cube(time=3) * level_slice(level=1)` comes back tracking `time` alone. The
          result's three planes are the cube's three steps; the single `level` plane was
          applied to each of them and is not an axis of the answer.
        - When the multi-band operand is a plain raster — or a classic-mode container — it
          has no band dimensions to lend, so the result tracks **none**, even if the
          one-band operand had some. `mask(time=1) * scene(3 bands)` is three bands with no
          band dimensions, so `isel`, `sel`, `coords` and `to_xarray` do not apply to it,
          `concat` refuses it, and `broadcast_like` refuses it too. The alternative would be
          to keep `time` at length three, which would claim three time steps where the
          operands held one; an unlabelled result is the honest answer.

          Such a result **cannot be labelled afterwards** — `expand_dims` only adds a
          length-one axis and `assign_coords` needs an existing dimension — so broadcast
          the operands before combining them if the layout matters. And note what `to_file`
          does with it: it succeeds, writing **one variable per plane** (`Band1`, `Band2`,
          …), so a three-step cube reopens as three unrelated single-plane variables.

        Args:
            other: The second operand, on this variable's grid.
            func: Binary callable applied to the operands' matching cells.
            band: Zero-based band to combine, or `None` for every band. A single band
                cannot carry a multi-band layout, so passing one skips the layout check and
                leaves the result without band dimensions.
            no_data_value: Sentinel for the result, as documented on
                `Analysis.combine`. Left unset it is derived — NaN for a floating result,
                none at all for an integer result that masked nothing.

        Returns:
            Dataset: The combined raster, on this variable's grid and of this variable's
            class. When `band` is `None` it carries the band dimensions of whichever
            operand supplied them, with the coordinates and time units that operand lacks
            taken from the other.

        Raises:
            ValueError: `band` is `None`, both operands carry band dimensions, their grids
                and band counts agree, and their dimension names or sizes differ. Differing
                coordinate values are not refused. Operands whose band counts differ skip the
                check — a single band against *n* broadcasts, and any other pair is refused by
                `Dataset.combine` with its own message.
            AlignmentError: The operands do not share a grid, raised by `Dataset.combine`.
                A grid mismatch is reported as this whether or not the band layouts also
                disagree, since the layouts are only compared on a shared grid.

        Examples:
            - The combined variable can still be selected by coordinate:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> nc = NetCDF.from_array(
              ...     np.ones((2, 3, 4, 5)),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 4.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2, 3])]),
              ... )
              >>> var = nc.get_variable("t")
              >>> total = var.combine(var, np.add)
              >>> total._band_dim_names, total._band_dim_sizes
              (('time', 'level'), (2, 3))
              >>> total.sel(time=6.0).band_count
              3

              ```
            - Two steps of one variable subtract, and `time` comes back without coordinates:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> var = NetCDF.from_array(
              ...     np.arange(4.0).reshape(2, 2, 1, 1),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2])]),
              ... ).get_variable("t")
              >>> change = var.sel(time=6.0) - var.sel(time=0.0)
              >>> change._band_dim_values_map
              {'time': None, 'level': [1, 2]}
              >>> change.read_array().ravel().tolist()
              [2.0, 2.0]

              ```
            - An operand without `time` coordinates takes the other operand's, in either order:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> var = NetCDF.from_array(
              ...     np.arange(4.0).reshape(2, 2, 1, 1),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2])]),
              ... ).get_variable("t")
              >>> change = var.sel(time=6.0) - var.sel(time=0.0)
              >>> later = var.sel(time=6.0)
              >>> change._band_dim_values_map
              {'time': None, 'level': [1, 2]}
              >>> (change + later)._band_dim_values_map
              {'time': [6.0], 'level': [1, 2]}
              >>> (later + change)._band_dim_values_map
              {'time': [6.0], 'level': [1, 2]}
              >>> (change + later).sel(time=6.0).read_array().ravel().tolist()
              [4.0, 5.0]

              ```
            - Operands whose dimension names differ are refused:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> geo = GeoReference(geo=(0.0, 1.0, 0.0, 4.0, 0.0, -1.0), epsg=4326)
              >>> by_time = NetCDF.from_array(
              ...     np.ones((2, 4, 5)), geo_ref=geo, variable_name="t",
              ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
              ... ).get_variable("t")
              >>> by_step = NetCDF.from_array(
              ...     np.ones((2, 4, 5)), geo_ref=geo, variable_name="t",
              ...     dims=ExtraDimensions(name="step", values=[0.0, 6.0]),
              ... ).get_variable("t")
              >>> by_time.combine(by_step, np.add)  # doctest: +IGNORE_EXCEPTION_DETAIL
              Traceback (most recent call last):
                ...
              ValueError: the operands' band dimensions do not line up

              ```
        """
        return super().combine(other, func, band=band, no_data_value=no_data_value)

    def _combine_layout_source(
        self, other: Any, band: int | None
    ) -> tuple[NetCDF | None, list[str], NetCDF | None]:
        """Check the operands' band layouts for `Analysis._combine` and name what to copy.

        Every route to a combined raster ends in `Analysis._combine` — the `combine` facade,
        `analysis.combine` called directly, and every operator through `combine` or `_fold` — so
        the check and the labelling live here and in `_label_combined`, not on any one door.

        Args:
            other: The right operand, or `None` when a scalar operator folds this variable with
                itself (`Analysis._fold`), which leaves no second layout to compare or fill from.
            band: The single band being combined, or `None` for all of them.

        Returns:
            tuple[NetCDF | None, list[str], NetCDF | None]: The operand whose layout describes
            the result, the dimensions whose coordinates the operands disagree on, and the
            partner `_label_combined` fills missing labels from: `other`, when this variable
            describes the result and `other` is a different `NetCDF` object that carries band
            dimensions too, else `None` (so `var + var` and a fold name none).
            `(None, [], None)` when a single band is combined, since one band cannot carry a
            multi-band layout.

        Raises:
            ValueError: As `_band_layout_source` raises.
        """
        layout: tuple[NetCDF | None, list[str], NetCDF | None] = (None, [], None)
        if band is None:
            layout_source, disagreeing = self._band_layout_source(other)
            paired = (
                layout_source is self
                and other is not self
                and isinstance(other, NetCDF)
                and bool(other._band_dim_names)
                # A broadcast partner holds one plane; its single stamp cannot label the
                # result's n, so a broadcast result takes its labels from its owner alone.
                and other.band_count == self.band_count
            )
            layout = (layout_source, disagreeing, other if paired else None)
        return layout

    def _label_combined(self, result: Any, source: Any) -> None:
        """Copy the band layout onto the raster `Analysis._combine` built.

        Dimensions the operands disagree on keep their name and length and lose their
        coordinates, and the units carried for them in `_band_dim_time_attrs`, since neither
        operand's stamps describe the result. Every other dimension takes its labels from
        whichever operand has them, so whether it comes back labelled, and which steps its
        labels name, does not depend on operand order: a dimension without coordinates on the
        layout source takes the partner's, with the partner's units, and agreeing coordinates
        without time units take the partner's units (`_fill_band_label`). Where both have
        agreeing coordinates, the layout source's raw values and units are kept, so hours
        `[0, 6]` and days `[0, 0.25]` since one origin keep whichever came first.

        Args:
            result: The combined raster.
            source: What `_combine_layout_source` returned: the source operand, the
                disagreeing dimensions, and the partner to fill missing labels from, or `None`.
        """
        layout_source, disagreeing, partner = source
        NetCDF._label_result_bands(result, layout_source)
        labelled = (
            (disagreeing or partner is not None)
            and isinstance(result, NetCDF)
            and tuple(result._band_dim_names) == tuple(layout_source._band_dim_names)
        )
        if labelled:
            partner_units = (
                {} if partner is None else partner._resolved_band_dim_time_attrs()
            )
            for name in result._band_dim_names:
                if name in disagreeing:
                    result._band_dim_values_map[name] = None
                    result._band_dim_time_attrs.pop(name, None)
                elif partner is not None:
                    NetCDF._fill_band_label(result, partner, name, partner_units)
            result._band_dim_name, result._band_dim_values = (
                NetCDF._derive_primary_band_view(
                    result._band_dim_names,
                    result._band_dim_values_map,
                    result._band_dim_sizes,
                    result._band_count,
                )
            )

    @staticmethod
    def _fill_band_label(
        result: NetCDF,
        partner: NetCDF,
        name: str,
        partner_units: dict[str, tuple[str, str]],
    ) -> None:
        """Give `result`'s dimension `name` the labels `partner` has and `result` lacks.

        Coordinates and their units travel together, so one operand's units never describe the
        other operand's stamps:

        - `result` has no coordinates and `partner` has them: take `partner`'s coordinates and
          `partner`'s units, or no units when `partner_units` has none for `name`;
        - both have coordinates (they agree, or `name` would have been unlabelled) and only
          `partner` carries units: take those units;
        - `partner` has no coordinates: leave `result` as it is, units included.

        Args:
            result: The labelled result, edited in place.
            partner: The operand that did not label `result`.
            name: The band dimension.
            partner_units: `partner`'s resolved units, `{dimension: (units, calendar)}`.
        """
        theirs = partner._band_dim_values_map.get(name)
        if theirs is not None and result._band_dim_values_map.get(name) is None:
            result._band_dim_values_map[name] = list(theirs)
            result._band_dim_time_attrs.pop(name, None)
            if name in partner_units:
                result._band_dim_time_attrs[name] = partner_units[name]
        elif (
            theirs is not None
            and name not in result._band_dim_time_attrs
            and name in partner_units
        ):
            result._band_dim_time_attrs[name] = partner_units[name]

    def _band_layout_source(self, other: Any) -> tuple[NetCDF | None, list[str]]:
        """The operand whose band dimensions describe a combined result, and where they disagree.

        Args:
            other: The second operand of `combine`, any raster; only a `NetCDF` carries
                band dimensions.

        Returns:
            tuple[NetCDF | None, list[str]]: The operand whose dimensions describe the result
            (`_band_layout_owner` — this variable when the counts agree and it has them, the
            multi-band operand when they broadcast, else `None`); and the dimensions whose
            coordinate values the two operands disagree on (empty unless both carry band
            dimensions on a shared grid with the same band count, so a broadcast pair
            reports none).

        Raises:
            ValueError: Both operands carry band dimensions, share a grid and a band count,
                and their dimension names or sizes differ. With differing grids or band counts
                the check is skipped, so `Dataset.combine` reports that more basic mismatch
                with its own error.
        """
        theirs = other if isinstance(other, NetCDF) and other._band_dim_names else None
        mine = self if self._band_dim_names else None
        disagreeing: list[str] = []
        if (
            mine is not None
            and theirs is not None
            and self.band_count == other.band_count
            and self.spatial.same_grid(other)
        ):
            difference = NetCDF._band_layout_difference(self, other)
            if difference is not None:
                raise ValueError(
                    f"the operands' band dimensions do not line up: {difference}. "
                    f"Select or rearrange one operand so both describe the same planes "
                    f"before combining them."
                )
            disagreeing = NetCDF._disagreeing_coordinates(self, other)
        return self._band_layout_owner(other, mine, theirs), disagreeing

    def _band_layout_owner(
        self, other: Any, mine: NetCDF | None, theirs: NetCDF | None
    ) -> NetCDF | None:
        """Which operand's band dimensions describe the combined result.

        With band counts that agree, this variable's layout wins when it has one, so the
        result keeps the left operand's dimensions and `other` only fills labels they lack.
        A **broadcast** pair is different: one operand holds a single plane, and a single
        plane's name, size and stamp cannot describe the *n* planes the result carries. So
        the operand that owns those planes owns the layout, whichever side it is on, and a
        one-band mask never relabels the scene it is applied to.

        Args:
            other: The right operand, or `None` for a fold (no second layout).
            mine: This variable when it carries band dimensions, else `None`.
            theirs: `other` when it is a `NetCDF` carrying band dimensions, else `None`.

        Returns:
            NetCDF | None: The operand describing the result, or `None` when neither does
            — including a broadcast whose multi-band side is a plain raster, which has no
            band dimensions to lend.
        """
        # Bare inequality, deliberately. `_check_combinable` is the one member that
        # reports a band-count mismatch, and `_band_layout_source` is documented to *skip*
        # its comparison for such a pair so that message is the one the caller sees —
        # a test pins that ordering. Conjoining `_broadcastable_bands` here reads as a
        # safety net but is not one: a 2-vs-3 pair that somehow arrived without the gate
        # would merely fall into the counts-agree branch instead. Raising here was worse
        # still, because it took the mismatch report away from `_check_combinable`.
        broadcast = isinstance(other, Dataset) and other.band_count != self.band_count
        if not broadcast:
            owner = mine if mine is not None else theirs
        elif self.band_count > other.band_count:
            owner = mine
        else:
            owner = theirs
        return owner

    @staticmethod
    def _band_layout_difference(left: NetCDF, right: NetCDF) -> str | None:
        """Describe how two operands' band dimension names or sizes disagree.

        Coordinate values are not compared here: operands that differ only in them still pair
        plane for plane, and `_disagreeing_coordinates` decides which dimensions lose them.

        Args:
            left: The left operand.
            right: The right operand.

        Returns:
            str | None: A phrase naming the mismatch — the names first, then the sizes — or
            `None` when both agree.
        """
        left_names, right_names = (
            tuple(left._band_dim_names),
            tuple(right._band_dim_names),
        )
        left_sizes, right_sizes = (
            tuple(left._band_dim_sizes),
            tuple(right._band_dim_sizes),
        )
        difference = None
        if left_names != right_names:
            difference = f"{list(left_names)} against {list(right_names)}"
        elif left_sizes != right_sizes:
            difference = (
                f"sizes {dict(zip(left_names, left_sizes))} against "
                f"{dict(zip(right_names, right_sizes))}"
            )
        return difference

    @staticmethod
    def _disagreeing_coordinates(left: NetCDF, right: NetCDF) -> list[str]:
        """The band dimensions whose coordinate values name different steps on the two operands.

        Expects the two layouts to agree in names and sizes. A dimension without coordinates on
        either side is skipped. When both operands find CF time units for a dimension (the
        nearest pair `_time_attr_candidates` yields) and the `(units, calendar)` pairs differ,
        its stamps are compared as decoded time instants, to the microsecond — hours `[0, 6]`
        and days `[0, 0.25]` since one origin agree, the same raw hours since two origins do
        not — and a stamp that will not decode counts as a disagreement. Otherwise — units on one
        side or neither, the same pair on both, or units that are not time units (`millibar`
        against `hPa`), which are never candidates — the raw values are compared with
        `_same_coordinates`. One object passed as both operands (`var + var`) agrees with itself
        at once, without looking up any units.

        Args:
            left: The left operand.
            right: The right operand.

        Returns:
            list[str]: The disagreeing dimensions, in the operands' order.

        Examples:
            - Hours and days since one origin name the same instants; other hours do not:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> def variable(stamps, units):
              ...     var = NetCDF.from_array(
              ...         np.ones((2, 1, 1)),
              ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
              ...         variable_name="t",
              ...         dims=ExtraDimensions(name="time", values=stamps),
              ...     ).get_variable("t")
              ...     var._band_dim_time_attrs = {"time": (units, "standard")}
              ...     return var
              >>> hours = variable([0.0, 6.0], "hours since 2000-01-01")
              >>> days = variable([0.0, 0.25], "days since 2000-01-01")
              >>> NetCDF._disagreeing_coordinates(hours, days)
              []
              >>> later = variable([0.0, 12.0], "hours since 2000-01-01")
              >>> NetCDF._disagreeing_coordinates(hours, later)
              ['time']

              ```
        """
        disagreeing: list[str] = []
        # An operand agrees with itself: `var + var` and `var.combine(var, ...)` pass one object
        # twice. A scalar operator's fold never gets here: `Analysis._combine` hands the hook no
        # other operand.
        for name in () if left is right else left._band_dim_names:
            left_values = left._band_dim_values_map.get(name)
            right_values = right._band_dim_values_map.get(name)
            if left_values is None or right_values is None:
                continue
            left_units = next(iter(left._time_attr_candidates(name)), None)
            right_units = next(iter(right._time_attr_candidates(name)), None)
            if left_units is None or right_units is None or left_units == right_units:
                agree = _same_coordinates(left_values, right_values)
            else:
                # To the microsecond, the finest step the converters resolve: sub-second axes are
                # real (a nanoseconds axis is decoded at microsecond resolution).
                instant = "%Y-%m-%d %H:%M:%S.%f"
                left_labels = left._decode_time_labels(
                    name, list(left_values), instant, strict=False
                )
                right_labels = right._decode_time_labels(
                    name, list(right_values), instant, strict=False
                )
                agree = left_labels is not None and left_labels == right_labels
            if not agree:
                disagreeing.append(name)
        return disagreeing

    @staticmethod
    def _label_result_bands(result: Any, source: NetCDF | None) -> None:
        """Copy `source`'s band dimensions onto an operator result that can carry them.

        Nothing is copied when `source` is `None`, when `result` is not a `NetCDF` (such as
        `NotImplemented` or a plain `Dataset`), or when the two band counts differ, so a
        single-band `combine(..., band=0)` result stays unlabelled.

        Args:
            result: What the operator or `combine` returned.
            source: The operand whose layout describes the result, or `None`.
        """
        if (
            source is not None
            and isinstance(result, NetCDF)
            and result.band_count == source.band_count
        ):
            NetCDF._copy_band_dim_metadata(result, source)

    def _replace_raster(self, new_raster: gdal.Dataset):
        """Replace the internal GDAL dataset, flushing the old one if different.

        Re-derives all base-class state (geotransform, CRS, band info, etc.)
        without resetting NetCDF-specific flags (_is_md_array, _is_subset). The
        previous raster is only flushed, never closed: a caller still holding the
        old handle (or a ``get_group()`` view of it) must keep a valid, unmutated
        dataset, which is why the variable-mutation ops copy-and-swap instead of
        mutating in place (#143). Do not change this to close the old raster.

        The geotransform and cell size re-derived here are `new_raster`'s, which is not the grid
        a collapsed spatial axis was stamped with, so `_restore_stamped_grid` puts a stamp back
        over them. A copying `add_variable` on a weighted result is the route that needs it.
        """
        old = self._raster
        if old is not None and old is not new_raster:
            old.FlushCache()
        # RasterBase state
        self._raster = new_raster
        if new_raster is not self._store_raster:
            self._store_raster = None
        self._derived_geotransform = None
        self._geotransform = new_raster.GetGeoTransform()
        self._cell_size = GeoTransform(*self._geotransform).cell_size
        self._restore_stamped_grid()
        self._file_name = new_raster.GetDescription()
        # Clear the borrowed container CRS *before* re-deriving the EPSG: it is
        # keyed to the variables behind the OLD raster, and `_get_epsg` reads
        # through it, so re-deriving first would just re-cache the stale answer.
        self._container_crs_cache = None
        self._crs_cache = None
        self.__dict__.pop("_cf_crs_cache", None)
        self._epsg = self._get_epsg()
        self._epsg_resolved = True
        self._rows = new_raster.RasterYSize
        self._columns = new_raster.RasterXSize
        self._band_count = new_raster.RasterCount
        self._block_size = [
            new_raster.GetRasterBand(i).GetBlockSize()
            for i in range(1, self._band_count + 1)
        ]
        # Dataset state
        self._no_data_value = [
            new_raster.GetRasterBand(i).GetNoDataValue()
            for i in range(1, self._band_count + 1)
        ]
        self._band_names = self._get_band_names()
        self._band_units = [
            new_raster.GetRasterBand(i).GetUnitType()
            for i in range(1, self._band_count + 1)
        ]
        # Invalidate caches
        self._cached_variables = None
        self._cached_meta_data = None

    def _invalidate_caches(self):
        """Invalidate cached variables and metadata."""
        self._cached_variables = None
        # Derived from `lon` / `lat`, so it belongs with the rest of the
        # derived state rather than only at the sites that reassign
        # `_geotransform` directly.
        self._derived_geotransform = None
        self._cached_meta_data = None
        # Clear the per-variable geostationary geotransform cache too: it is keyed by
        # variable name and derived from the backing geometry, so it must not survive a
        # raster swap / in-place update that could change that geometry (latent staleness).
        self._geostationary_gt_cache = {}
        # Same reasoning for the borrowed container CRS: it is derived from the
        # variables behind the old raster, so a swap must not carry it over.
        self._container_crs_cache = None
        self._crs_cache = None
        self.__dict__.pop("_cf_crs_cache", None)
        # Clear the memoised EPSG rather than re-deriving it here: eager
        # re-derivation walks every variable again and immediately re-fills the
        # cache this method just invalidated. The `epsg` property resolves it on
        # the next read instead.
        self._epsg = None
        self._epsg_resolved = False

    @property
    def is_subset(self) -> bool:
        """Whether this object represents a single-variable subset.

        Returns:
            bool: True if the dataset is a variable subset extracted
                via `get_variable()`.
        """
        return self._is_subset

    @property
    def is_md_array(self):
        """Whether this dataset was opened in multidimensional mode.

        Returns:
            bool: True if the dataset was opened via
                `gdal.OF_MULTIDIM_RASTER` and supports groups,
                MDArrays, and dimensions.
        """
        return self._is_md_array

    def to_file(  # type: ignore[override]
        self,
        path: str | Path,
        **kwargs: Any,
    ) -> None:
        """Save the dataset to disk.

        For `.nc` / `.nc4` files the full multidimensional structure
        (groups, dimensions, variables, attributes) is preserved via
        `CreateCopy` with the netCDF driver. For other extensions
        (e.g. `.tif`), the parent `Dataset.to_file` is used — but only
        on variable subsets, not on root MDIM containers.

        Args:
            path: Destination file path. The extension determines the
                output driver (`.nc` -> netCDF, `.tif` -> GeoTIFF, etc.).
            **kwargs: Forwarded to `Dataset.to_file` for non-NetCDF
                extensions (e.g. `tile_length`, `creation_options`).

        Raises:
            RuntimeError: If the netCDF `CreateCopy` call fails.
            ValueError: If a root MDIM container is saved to a non-NC
                extension (use `.nc` or extract a variable first).
        """
        path = Path(path)
        extension = path.suffix[1:].lower()
        if extension in ("nc", "nc4"):
            self._write_netcdf(path)
        elif self._is_md_array and not self._is_subset:
            raise ValueError(
                "Cannot save a multidimensional NetCDF container as "
                f"'{extension}'. Use .nc extension or extract a "
                "variable first with .get_variable()."
            )
        else:
            super().to_file(path, **kwargs)

    def _write_netcdf(self, path: Path) -> None:
        """Write this dataset to a netCDF file, preserving the declared (or absent) convention.

        Uses GDAL's ``CreateCopy`` and falls back to a manual multidim copy when that raises on some
        dimension layouts (#584), then strips any writer-injected ``Conventions`` if the source declared
        none (#583).
        """
        source_conventions = self.global_attributes.get("Conventions")
        # Resolved before the `try`, not inside it: materialising is not part of
        # the operation whose RuntimeError the fallback below is meant to catch,
        # and a failure to materialise should surface rather than be mistaken
        # for the dimension-layout problem the manual copy exists to work around.
        source = self._copy_source
        try:
            dst = gdal.GetDriverByName("netCDF").CreateCopy(str(path), source, 0)
        except RuntimeError:
            # GDAL's netCDF CreateCopy raises on some dimension layouts (re-declaring a dimension
            # name, #584). Fall back to a manual multidim copy that creates each dimension once.
            self._remove_path(path)
            self._manual_netcdf_copy(path)
        else:
            if dst is None:
                raise RuntimeError(f"Failed to save NetCDF to {path}")
            dst.FlushCache()
            dst = None
        if source_conventions is None:
            self._strip_injected_conventions(path)

    @staticmethod
    def _remove_path(path: Path) -> None:
        """Delete a (possibly GDAL-locked) file, retrying once after a GC pass."""
        if not path.exists():
            return
        try:
            path.unlink()
        except OSError:
            gc.collect()
            path.unlink()

    def _manual_netcdf_copy(self, path: str | Path) -> None:
        """Write this container to netCDF by copying each variable explicitly.

        Fallback for files where GDAL's ``CreateCopy`` raises while re-declaring dimensions (#584).
        Each dimension is created once via ``_add_md_array_to_group`` -> ``_resolve_dst_dimensions``.
        Root-level variables and global attributes only; hierarchical groups are not handled here
        (those files copy fine through ``CreateCopy``), so this raises if the source has subgroups.
        """
        src_rg = self._working_group()
        if src_rg is None:
            raise RuntimeError(
                "manual netCDF copy requires a multidimensional container"
            )
        if src_rg.GetGroupNames():
            raise RuntimeError(
                f"manual netCDF copy of {path} does not support hierarchical groups"
            )
        dst = gdal.GetDriverByName("netCDF").CreateMultiDimensional(
            str(path), ["FORMAT=NC4"]
        )
        dst_rg = dst.GetRootGroup()
        self._copy_md_array_attributes(src_rg, dst_rg)
        for var_name in src_rg.GetMDArrayNames():
            self._add_md_array_to_group(dst_rg, var_name, src_rg.OpenMDArray(var_name))
        dst_rg = None
        dst.FlushCache()
        dst = None
        gc.collect()

    @staticmethod
    def _md_array_to_numpy(md_arr):
        """Read an MDArray to numpy, using the list-based ``Read()`` for string arrays.

        GDAL's SWIG ``ReadAsArray`` raises ("String buffer data type not supported") on string MDArrays,
        so character coordinate/data variables are read via ``Read()`` and wrapped as a numpy array (#586,
        same limitation handled in ``_add_md_array_to_group`` for #565).
        """
        if md_arr.GetDataType().GetClass() == gdal.GEDTC_STRING:
            return np.array(md_arr.Read())
        return md_arr.ReadAsArray()

    @staticmethod
    def _strip_injected_conventions(path: str | Path) -> None:
        """Delete a writer-injected ``Conventions`` global attribute from a just-written netCDF file.

        GDAL's netCDF driver adds a default ``Conventions`` (e.g. ``CF-1.6``) on write when the source
        declares none. Callers use this to keep a no-convention file from being relabelled as CF (#583).
        """
        ds = gdal.OpenEx(str(path), gdal.OF_MULTIDIM_RASTER | gdal.OF_UPDATE)
        if ds is None:
            return
        rg = ds.GetRootGroup()
        if rg is not None and any(
            a.GetName() == "Conventions" for a in rg.GetAttributes()
        ):
            try:
                rg.DeleteAttribute("Conventions")
            except RuntimeError:
                pass  # driver may not support attribute deletion — leave it
        rg = None
        ds.FlushCache()
        ds = None
        gc.collect()

    def copy(self, path: str | Path | None = None) -> NetCDF:
        """Create a deep, standalone copy of this dataset.

        The copy keeps this instance's concrete type (a container copies to a
        ``Container``, a variable to a ``Variable``) and its CF packing metadata
        (``scale`` / ``offset`` / variable attributes / band-dim layout). It
        is **independent**, though: a copied variable is a self-contained classic raster, not
        a live subset of the original's parent, so ``is_subset`` is ``False`` and it carries
        no ``_parent_nc`` / ``_source_var_name``. That keeps pickling sound — a copy
        reconstructs from its own data rather than reaching back into the parent store.

        Args:
            path: Destination file path. If None, the copy is created
                in memory using the MEM driver. Defaults to None.

        Returns:
            NetCDF: A new, standalone NetCDF object (same concrete subclass as ``self``).

        Raises:
            RuntimeError: If `CreateCopy` fails.
        """
        if path is None:
            path = ""
            driver = "MEM"
        else:
            driver = "netCDF"

        src = gdal.GetDriverByName(driver).CreateCopy(str(path), self._copy_source)
        if src is None:
            raise RuntimeError(f"Failed to copy NetCDF dataset to '{path}'")
        # Preserve both the concrete type AND the variable-subset / origin identity: a copy
        # of a container is a container; a copy of a variable is a usable variable subset.
        # The fresh CreateCopy would otherwise reset these flags through __init__ (defaulting
        # open_as_multi_dimensional=True, _is_subset=False), so re-open in the source's mode
        # and carry the subset/origin + cached variable metadata over.
        # A copied variable subset is a standalone single-variable *classic* raster (its
        # backing is the materialized AsClassicDataset view), not an MDIM store — so it must
        # open classic. A container copy keeps the container's MDIM/classic mode.
        copy_is_md = self._is_md_array and not self._is_subset
        result = type(self)(src, access="write", open_as_multi_dimensional=copy_is_md)
        # A copy is an INDEPENDENT, materialized dataset: its data no longer lives at
        # parent_file::source_var_name, so it must NOT inherit the subset/origin identity
        # that __reduce__ uses to reconstruct (that would pickle-reconstruct from the PARENT,
        # silently reading the wrong data, or fail on a self-contained copy whose band names
        # differ). It keeps its concrete type and CF packing metadata, but is standalone.
        result._is_subset = False
        result._parent_nc = None
        result._source_var_name = None
        result._rebuilt_in_memory = False
        result._md_array_dims = self._md_array_dims
        result._geostationary_scaled = self._geostationary_scaled
        result._variable_attrs = self._variable_attrs
        result._scale = self._scale
        result._offset = self._offset
        NetCDF._copy_band_dim_metadata(result, self)
        return result

    @staticmethod
    def _create_dimension(
        group: gdal.Group,
        dim_name: str,
        dtype,
        values: np.ndarray,
        dim_type=None,
        set_indexing: bool = True,
        is_geographic: bool | None = True,
    ) -> gdal.Dimension:
        """Create a dimension with its coordinate array and CF attributes.

        Args:
            group: GDAL root group.
            dim_name: Dimension name.
            dtype: GDAL ExtendedDataType.
            values: Coordinate values.
            dim_type: GDAL dimension type constant.
            set_indexing: If True, call SetIndexingVariable (works
                on MEM driver). If False, skip it (required for
                netCDF driver which doesn't support it).
            is_geographic: If True, coordinate units are degrees; if False,
                metres; if `None`, the CRS is unknown and only the axis role
                is written (no units or standard_name). Defaults to True.

        Returns:
            gdal.Dimension
        """
        dim = group.CreateDimension(dim_name, dim_type, None, values.shape[0])
        coord_arr = group.CreateMDArray(dim_name, [dim], dtype)
        if dtype.GetClass() == gdal.GEDTC_STRING:
            # A string MDArray takes a Python list of `str`; a NumPy string array sent
            # through `Write` is force-cast to float64 by the SWIG binding and raises. This
            # is the channel `interop` uses for a text coordinate (#565, #1181).
            coord_arr.Write(np.asarray(values).astype(str).tolist())
        else:
            coord_arr.Write(values)
        if set_indexing:
            dim.SetIndexingVariable(coord_arr)
        cf_attrs = build_coordinate_attrs(dim_name, is_geographic)
        if cf_attrs:
            write_attributes_to_md_array(coord_arr, cf_attrs)
        return dim

    @staticmethod
    def create_main_dimension(
        group: gdal.Group, dim_name: str, dtype: Any, values: np.ndarray
    ) -> gdal.Dimension:
        """Create a NetCDF dimension with an indexing variable.

        The dimension type is inferred from `dim_name`:
        `y`/`lat`/`latitude` -> horizontal Y,
        `x`/`lon`/`longitude` -> horizontal X,
        `bands`/`time` -> temporal.

        The dimension is registered in the group together with a matching MDArray that
        stores the coordinate values. When `dtype` is a string `ExtendedDataType`
        (`gdal.ExtendedDataType.CreateString()`) a text coordinate axis — WRF's `Time` of
        `'2000-01-24_12:00:00'` stamps, a scenario name — is written through the GDAL string
        channel (a Python list of `str`); a NumPy string array sent straight to `Write` is
        force-cast to float64 by the SWIG binding and would raise (#1181).

        Args:
            group: Root group (or sub-group) of the multidimensional dataset.
            dim_name: Name of the dimension to create.
            dtype: GDAL `ExtendedDataType` for the indexing variable — a numeric type for a
                numeric axis, or `gdal.ExtendedDataType.CreateString()` for a text axis.
            values: Coordinate values for the dimension; numeric or text to match `dtype`.

        Returns:
            gdal.Dimension: The newly created dimension.

        Examples:
            - Create a numeric coordinate axis and read the stored values back:
                ```python
                >>> import numpy as np
                >>> from osgeo import gdal
                >>> from pyramids.netcdf import NetCDF
                >>> ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("mem")
                >>> group = ds.GetRootGroup()
                >>> f64 = gdal.ExtendedDataType.Create(gdal.GDT_Float64)
                >>> dim = NetCDF.create_main_dimension(group, "time", f64, np.array([0.0, 1.0, 2.0]))
                >>> dim.GetSize()
                3
                >>> group.OpenMDArray("time").ReadAsArray().tolist()
                [0.0, 1.0, 2.0]

                ```
            - Create a text coordinate axis (e.g. WRF `Time` stamps) via the string channel:
                ```python
                >>> import numpy as np
                >>> from osgeo import gdal
                >>> from pyramids.netcdf import NetCDF
                >>> ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("mem")
                >>> group = ds.GetRootGroup()
                >>> sdt = gdal.ExtendedDataType.CreateString()
                >>> stamps = np.array(["2000-01-24_12:00:00", "2000-01-24_18:00:00"])
                >>> dim = NetCDF.create_main_dimension(group, "Time", sdt, stamps)
                >>> dim.GetSize()
                2
                >>> group.OpenMDArray("Time").Read()
                ['2000-01-24_12:00:00', '2000-01-24_18:00:00']

                ```
        """
        if dim_name in ["y", "lat", "latitude"]:
            dim_type = gdal.DIM_TYPE_HORIZONTAL_Y
        elif dim_name in ["x", "lon", "longitude"]:
            dim_type = gdal.DIM_TYPE_HORIZONTAL_X
        elif dim_name in ["bands", "time"]:
            dim_type = gdal.DIM_TYPE_TEMPORAL
        else:
            dim_type = None
        dim = group.CreateDimension(dim_name, dim_type, None, values.shape[0])
        x_values = group.CreateMDArray(dim_name, [dim], dtype)
        if dtype.GetClass() == gdal.GEDTC_STRING:
            # A string MDArray takes a Python list of `str`; a NumPy string array through
            # `Write` is force-cast to float64 by the SWIG binding and raises (#1181).
            x_values.Write(np.asarray(values).astype(str).tolist())
        else:
            x_values.Write(values)
        dim.SetIndexingVariable(x_values)
        return dim

    @classmethod
    def from_array(
        cls,
        arr: np.ndarray,
        *,
        geo_ref: GeoReference,
        no_data_value: Any | list = DEFAULT_NO_DATA_VALUE,
        path: str | Path | None = None,
        variable_name: str | None = None,
        dims: ExtraDimensions | None = None,
        encoding: Encoding | None = None,
        attrs: CFAttributes | None = None,
        spatial_names: tuple[str, str] | None = None,
    ) -> Container:
        """Build a :class:`Container` from a NumPy array.

        Declared with the engine's real parameters rather than ``*args,
        **kwargs``. The bare form made an incompatible override invisible to
        static checkers, which is how this constructor came to accept a
        different keyword set from its own base class unnoticed.

        The return type is :class:`Container`, not ``cls``: this always builds a
        container regardless of the subtype it is invoked on. ``Container`` adds
        no public API over :class:`NetCDF`, so nothing is lost — but the
        annotation now says so instead of implying ``cls`` is honoured.

        Args:
            arr: The array to wrap. 2-D is `(rows, cols)`; the leading axes of
                a 3-D+ array are the non-spatial dimensions described by
                `dims`.
            geo_ref: How the array maps to space — an affine ``geo`` transform,
                or a ``top_left_corner`` + ``cell_size``, plus the ``epsg``.
                Required and keyword-only, exactly as on
                :meth:`pyramids.dataset.Dataset.from_array`; that shared core
                is what lets one caller build either class.
            no_data_value: Sentinel marking cells outside the domain.
            path: Destination. Unlike
                :meth:`pyramids.dataset.Dataset.from_array`, the extension does
                not choose the format here: this builds a multidimensional
                store, which only the netCDF driver can carry, so `None`
                (default) builds it in memory and any path writes netCDF. The
                extension is validated rather than obeyed — it must name netCDF
                (`.nc`, or its `.nc4` alias), and one naming another format
                raises instead of writing netCDF bytes under a misleading name.
            variable_name: Name for the created variable. Defaults to
                ``"data"``.
            dims: Non-spatial (time / level / …) dimensions of a 3-D+ array.
                Ignored for 2-D arrays.
            encoding: On-disk write options (chunking, compression); effective
                only with a `path`.
            attrs: CF global attributes.
            spatial_names: `(row, column)` names for the two spatial dimensions.
                `None` (default) names them `y` / `x`; a rebuild passes the source
                store's own so a result keeps `latitude` / `longitude` (#1180).
                Two distinct non-empty strings, or a `ValueError`.

        Returns:
            Container: The newly created store.

        Raises:
            ValueError: `geo_ref` carries neither a ``geo`` nor a complete
                ``top_left_corner`` + ``cell_size`` pair; `spatial_names` is not
                two distinct non-empty strings; or `dims.attrs` is keyed by a
                dimension this array does not have.
            DriverNotExistError: `path` has no extension, or one the driver
                catalog does not know.
            FileFormatNotSupportedError: `path`'s extension names a driver
                other than netCDF (e.g. `"out.tif"`). Build the raster with
                :meth:`pyramids.dataset.Dataset.from_array` to write that
                format.

        Examples:
            - The grid is named `y` / `x` unless the caller says otherwise, and
              `spatial_names` is how a rebuild keeps the source store's own names:

                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import GeoReference, NetCDF
                >>> cells = np.arange(4.0).reshape(2, 2)
                >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326)
                >>> sorted(NetCDF.from_array(cells, geo_ref=geo_ref, variable_name="t").dimension_names)
                ['x', 'y']
                >>> named = NetCDF.from_array(
                ...     cells,
                ...     geo_ref=geo_ref,
                ...     variable_name="t",
                ...     spatial_names=("latitude", "longitude"),
                ... )
                >>> sorted(named.dimension_names)
                ['latitude', 'longitude']

                ```
            - A band dimension declares the CF units it is given, and they survive the
              write — which is what lets the written file decode its own stamps:

                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
                >>> cube = NetCDF.from_array(
                ...     np.arange(8.0).reshape(2, 2, 2),
                ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
                ...     variable_name="t",
                ...     dims=ExtraDimensions(
                ...         name="time",
                ...         values=[0.0, 6.0],
                ...         attrs={"time": {"units": "hours since 2000-01-01", "calendar": "standard"}},
                ...     ),
                ... )
                >>> cube.get_variable("t")._resolved_band_dim_time_attrs()
                {'time': ('hours since 2000-01-01', 'standard')}
                >>> cube.get_variable("t")._band_dim_values_map["time"]
                [0.0, 6.0]

                ```
            - CF attributes addressed to an axis the array does not have are refused,
              rather than quietly written nowhere:

                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
                >>> try:
                ...     NetCDF.from_array(
                ...         np.arange(8.0).reshape(2, 2, 2),
                ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
                ...         variable_name="t",
                ...         dims=ExtraDimensions(
                ...             name="time", values=[0.0, 6.0], attrs={"tim": {"units": "hours"}}
                ...         ),
                ...     )
                ... except ValueError as error:
                ...     print(error)
                attrs names 'tim', which is not among the dimensions of this array (time).

                ```
            - Wrap a 2-D array and read the variable back off the container:
                ```python
                >>> import numpy as np
                >>> from pyramids.netcdf import NetCDF, GeoReference
                >>> container = NetCDF.from_array(
                ...     np.arange(6, dtype="float32").reshape(2, 3),
                ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0)),
                ...     variable_name="temperature",
                ... )
                >>> container.variable_names
                ['temperature']
                >>> container.get_variable("temperature").read_array().tolist()
                [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]

                ```
            - The same call shape works for
              :meth:`pyramids.dataset.Dataset.from_array`, which is the point
              of the shared core:
                ```python
                >>> import numpy as np
                >>> from pyramids.dataset import Dataset
                >>> from pyramids.netcdf import NetCDF, GeoReference
                >>> ref = GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0))
                >>> arr = np.ones((2, 3), dtype="float32")
                >>> [
                ...     type(cls.from_array(arr, geo_ref=ref)).__name__
                ...     for cls in (Dataset, NetCDF)
                ... ]
                ['Dataset', 'Container']

                ```
            - A path naming another format is refused, not written under a
              misleading name:
                ```python
                >>> import numpy as np
                >>> from pyramids.errors import FileFormatNotSupportedError
                >>> from pyramids.netcdf import NetCDF, GeoReference
                >>> try:
                ...     NetCDF.from_array(
                ...         np.zeros((2, 2), dtype="float32"),
                ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0)),
                ...         path="lies.tif",
                ...     )
                ... except FileFormatNotSupportedError as error:
                ...     print(str(error).split(", but ")[0])
                NetCDF.from_array writes a multidimensional netCDF store

                ```
        """
        return _variables.from_array(
            arr,
            geo_ref=geo_ref,
            no_data_value=no_data_value,
            path=path,
            variable_name=variable_name,
            dims=dims,
            encoding=encoding,
            attrs=attrs,
            spatial_names=spatial_names,
        )

    @classmethod
    def from_dataframe(
        cls,
        df: pd.DataFrame,
        *,
        crs: str | int | None = None,
        x: str | None = None,
        y: str | None = None,
        variables: str | Sequence[str] | None = None,
        no_data_value: Any = np.nan,
        path: str | Path | None = None,
    ) -> Container:
        """Build a :class:`Container` from a `MultiIndex` DataFrame — the inverse of `to_dataframe`.

        The frame is indexed by its dimensions: the innermost two index levels are the
        `(y, x)` grid axes and any outer levels are band dimensions, so
        `NetCDF.from_dataframe(nc.to_dataframe(), crs=nc.epsg)` reproduces `nc`'s values,
        variables, band coordinates, grid and CRS (the geotransform is recovered by
        differencing cell centres); gaps come back as `NaN` unless `no_data_value=` restores a
        sentinel. A DataFrame carries no georeferencing, so the geotransform is inferred from
        the `x` / `y` cell-centre coordinates (a regular grid is required; a single-row or
        single-column axis carries no spacing and is refused) and the CRS is taken from
        `crs`. The result is always north-up; band-dimension coordinates keep their
        first-appearance order. See the engine method for the full contract.

        Args:
            df: A DataFrame on a `pandas.MultiIndex` of at least two named levels. The
                innermost two are the row (`y`) and column (`x`) axes unless `x` / `y` name
                them; further levels are band dimensions, outermost first. Each non-index
                column becomes a data variable. A tidy frame on a plain index is refused.
            crs: The CRS for the result, an EPSG code or a CRS string. `None` (default)
                leaves it unset — a DataFrame carries none, so a full round trip needs
                `crs=nc.epsg` to recover it.
            x: The index level holding the column (x) coordinates; defaults to the innermost
                level.
            y: The index level holding the row (y) coordinates; defaults to the
                second-innermost level.
            variables: Which columns become data variables, as a name or a sequence; `None`
                (default) takes every column.
            no_data_value: Sentinel stamped on gaps (`NaN` cells and cells absent from the
                frame). Defaults to `np.nan`, so gaps stay `NaN` and each rebuilt variable
                declares `NaN` as its nodata; pass `no_data_value=` to restore a sentinel.
            path: Destination — `None` (default) builds in memory, a `.nc` path writes it,
                as :meth:`from_array`.

        Returns:
            Container: The rebuilt store, one variable per chosen column, on the inferred
                grid.

        Raises:
            ValueError: `df` is not indexed by a `MultiIndex` of at least two named levels;
                a named `x` / `y` level is missing or the two coincide; there are no value
                columns or a requested one is absent; two selected columns stringify to the
                same variable name; the index has duplicate rows; or an `x` / `y` axis is
                irregular or has fewer than two coordinates.

        Examples:
            - Round-trip a two-step cube through pandas and back:

              ```python
              >>> import numpy as np
              >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
              >>> nc = NetCDF.from_array(
              ...     np.arange(8.0).reshape(2, 2, 2),
              ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
              ...     variable_name="t",
              ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
              ... )
              >>> back = NetCDF.from_dataframe(nc.to_dataframe(), crs=nc.epsg)
              >>> back.get_variable("t")._band_dim_values_map["time"]
              [0.0, 6.0]
              >>> back.epsg
              4326

              ```
        """
        return _variables.from_dataframe(
            df,
            crs=crs,
            x=x,
            y=y,
            variables=variables,
            no_data_value=no_data_value,
            path=path,
        )

    @staticmethod
    def _resolve_dst_dimensions(dst_group, src_dims):
        """Map source dimensions onto `dst_group`, creating any that are missing.

        Reuses a destination dimension when one already exists with the same
        name and size (so same-group copies like `rename_variable` keep sharing
        their dimensions); otherwise recreates it from the source dimension's
        name, type, direction, and size. This lets `_add_md_array_to_group`
        copy a variable into a *different* container's group.
        """
        existing = {dim.GetName(): dim for dim in (dst_group.GetDimensions() or [])}
        resolved = []
        for dim in src_dims:
            size = dim.GetSize()
            match = existing.get(dim.GetName())
            if match is not None and match.GetSize() == size:
                resolved.append(match)
                continue
            # Name taken by a different-sized dimension: fall back to a size-suffixed name (matching
            # _get_or_create_dimension), reusing a same-size match and uniquifying on any collision.
            name = dim.GetName() if match is None else f"{dim.GetName()}_{size}"
            existing_match = existing.get(name)
            if existing_match is not None and existing_match.GetSize() == size:
                resolved.append(existing_match)
                continue
            suffix = 1
            while name in existing and existing[name].GetSize() != size:
                name = f"{dim.GetName()}_{size}_{suffix}"
                suffix += 1
            new_dim = dst_group.CreateDimension(
                name, dim.GetType(), dim.GetDirection(), size
            )
            existing[name] = new_dim
            resolved.append(new_dim)
        return resolved

    @staticmethod
    def _rewritten_attribute_value(name, value, dim_rename):
        """`value` with every whole-word mention of a renamed dimension replaced.

        Applied only to the CF attributes listed in
        :data:`_CF_DIMENSION_REFERENCING_ATTRIBUTES`, and in one pass, so exchanging two names
        (`rename_dims(time="plev", plev="time")`) does not substitute each twice and land back
        where it started.

        Args:
            name: The attribute's name.
            value: Its string value, or `None` when GDAL cannot read it back.
            dim_rename: `{old: new}` dimension renames, empty or `None` for a copy that renames
                nothing.

        Returns:
            The value to write: rewritten for an attribute that names dimensions, unchanged
            otherwise.
        """
        rewritten = value
        if dim_rename and value and name in _CF_DIMENSION_REFERENCING_ATTRIBUTES:
            alternatives = "|".join(re.escape(old) for old in dim_rename)
            rewritten = re.sub(
                rf"\b(?:{alternatives})\b", lambda m: dim_rename[m.group(0)], value
            )
        return rewritten

    @staticmethod
    def _copy_md_array_attributes(src_mdarray, dst_mdarray, dim_rename=None):
        """Copy every attribute from one MDArray to another, preserving dtype.

        GDAL's `Attribute.Write` routes through `WriteRaw`, which rejects numeric
        tuples, so each attribute is written with the type-specific call that
        matches its class (string vs integer vs floating point) and arity.

        Args:
            src_mdarray: The array (or group) to read the attributes from.
            dst_mdarray: The array (or group) to create them on.
            dim_rename: `{old: new}` dimension renames the copy is part of. A CF attribute whose
                value names a dimension is rewritten through them, so a renamed axis leaves no
                attribute pointing at a dimension the store no longer has (M4). `None` (the
                default) copies every value verbatim.
        """
        for attr in src_mdarray.GetAttributes():
            name = attr.GetName()
            data_type = attr.GetDataType()
            count = attr.GetTotalElementsCount()
            dims = [] if count <= 1 else [count]
            if data_type.GetClass() == gdal.GEDTC_STRING:
                new_attr = dst_mdarray.CreateAttribute(
                    name, dims, gdal.ExtendedDataType.CreateString()
                )
                if count <= 1:
                    new_attr.WriteString(
                        NetCDF._rewritten_attribute_value(
                            name, attr.ReadAsString(), dim_rename
                        )
                    )
                else:
                    new_attr.WriteStringArray(
                        [
                            NetCDF._rewritten_attribute_value(name, item, dim_rename)
                            for item in attr.ReadAsStringArray()
                        ]
                    )
                continue
            numeric_type = data_type.GetNumericDataType()
            new_attr = dst_mdarray.CreateAttribute(
                name, dims, gdal.ExtendedDataType.Create(numeric_type)
            )
            NetCDF._write_numeric_attribute(new_attr, attr, numeric_type, count)

    @staticmethod
    def _write_numeric_attribute(new_attr, src_attr, numeric_type, count):
        """Write a numeric attribute with the GDAL call matching its dtype class and arity.

        64-bit integer codes (``GDT_Int64`` / ``GDT_UInt64``) use the 64-bit ``WriteInt64`` /
        ``ReadAsInt64`` calls; the 32-bit ``WriteInt`` / ``ReadAsInt`` would truncate large values
        (e.g. an Int64 ``_FillValue``). Other integer codes use the 32-bit integer calls and
        everything else uses the floating-point calls.

        Args:
            new_attr: The destination ``gdal.Attribute`` to write into.
            src_attr: The source ``gdal.Attribute`` to read from.
            numeric_type: The GDAL numeric data type code of the attribute.
            count: Total element count (``<= 1`` is written as a scalar, otherwise as an array).
        """
        is_int64 = numeric_type in (gdal.GDT_Int64, gdal.GDT_UInt64)
        is_integer = numeric_type in _GDAL_INTEGER_DTYPES
        if count <= 1:
            if is_int64:
                new_attr.WriteInt64(src_attr.ReadAsInt64())
            elif is_integer:
                new_attr.WriteInt(src_attr.ReadAsInt())
            else:
                new_attr.WriteDouble(src_attr.ReadAsDouble())
        elif is_int64:
            new_attr.WriteInt64Array(src_attr.ReadAsInt64Array())
        elif is_integer:
            new_attr.WriteIntArray(src_attr.ReadAsIntArray())
        else:
            new_attr.WriteDoubleArray(src_attr.ReadAsDoubleArray())

    @staticmethod
    def _copy_md_array_packing(src_mdarray, dst_mdarray) -> None:
        """Carry the CF packing -- scale, offset and fill value -- onto a copy.

        Split out of `_add_md_array_to_group`, whose numeric branch alone needs
        it: none of the three means anything for a string array.

        Args:
            src_mdarray: The array being copied.
            dst_mdarray: The freshly created copy, not yet written.
        """
        scale = src_mdarray.GetScale()
        if scale is not None:
            dst_mdarray.SetScale(scale)
        offset = src_mdarray.GetOffset()
        if offset is not None:
            dst_mdarray.SetOffset(offset)
        ndv = src_mdarray.GetNoDataValue()
        if ndv is not None:
            try:
                dst_mdarray.SetNoDataValueDouble(ndv)
            except (RuntimeError, TypeError, ValueError):
                pass

    @staticmethod
    def _copy_md_array_labels(src_mdarray, dst_mdarray, dim_rename=None) -> None:
        """Carry the unit, spatial reference and attributes onto a copy.

        Shared by both branches of `_add_md_array_to_group`. The string branch
        used to copy the attributes alone, so a string auxiliary came out of a
        crop with a unit of `''` where it went in with `'1'`.

        Args:
            src_mdarray: The array being copied.
            dst_mdarray: The freshly created copy, not yet written.
            dim_rename: `{old: new}` dimension renames, passed on to
                :meth:`_copy_md_array_attributes` so a CF attribute naming a renamed dimension
                is rewritten rather than copied verbatim. `None` copies verbatim.
        """
        unit = src_mdarray.GetUnit()
        if unit:
            dst_mdarray.SetUnit(unit)
        srs = src_mdarray.GetSpatialRef()
        if srs is not None:
            dst_mdarray.SetSpatialRef(srs)
        NetCDF._copy_md_array_attributes(src_mdarray, dst_mdarray, dim_rename)

    @staticmethod
    def _add_md_array_to_group(dst_group, var_name, src_mdarray):
        """Copy an MDArray into `dst_group`, preserving data, packing, and metadata.

        Dimensions are resolved against the destination group, so the source may
        live in a different container. A numeric array carries its on-disk packing
        (`scale`/`offset`), `unit`, no-data value, spatial reference, and all
        variable attributes across; scale/offset/unit/no-data are set before the
        data is written so the netCDF driver accepts the fill value. A string array
        takes the list `Read` / `Write` path instead and carries its values and
        attributes only: its `unit`, no-data value and spatial reference are not
        copied. A string array whose write fails is deleted before the error
        propagates, so the destination never keeps a half-built copy.

        Args:
            dst_group: The `gdal.Group` to create the copy in.
            var_name: The name to create it under.
            src_mdarray: The `gdal.MDArray` to copy.

        Raises:
            RuntimeError: GDAL refuses the string write; its Python `Write` rejects
                every string array of rank >= 2 ("not a unicode string, bytes,
                bytearray or memoryview").
            TypeError: The string source holds a NULL entry, which `Write` rejects
                ("sequence must contain strings").
            ValueError: The source is a compound. `numpy_to_gdal_dtype` has no GDAL
                type for its structured dtype, so nothing is created.
        """
        NetCDF._recreate_md_array(
            dst_group,
            var_name,
            src_mdarray,
            NetCDF._resolve_dst_dimensions(dst_group, src_mdarray.GetDimensions()),
            derive_dtype_from_values=True,
        )

    @staticmethod
    def _recreate_md_array(
        dst_group,
        var_name,
        src_mdarray,
        dst_dims,
        dim_rename=None,
        derive_dtype_from_values=False,
    ):
        """Create `var_name` in `dst_group` bound to `dst_dims`, byte-for-byte.

        The store-level sibling of :meth:`_add_md_array_to_group`: the caller supplies the
        destination dimensions explicitly (already created under their final names), so an
        array can be re-bound onto a *renamed* axis rather than one resolved by name. The
        source's exact on-disk dtype is kept — taken from `GetDataType()`, not re-derived from
        the read-back array — so a packed `int16` survivor stays `int16` with its
        `scale`/`offset`/fill, and the unit, spatial reference and attributes come across too
        (:meth:`_copy_md_array_packing` / :meth:`_copy_md_array_labels`). This is what lets
        `rename_dims` / `drop_dims` rebuild a container without unpacking a survivor the way
        reading it as a raster would. A string array whose write fails is deleted before the
        error propagates, as in :meth:`_add_md_array_to_group`.

        Args:
            dst_group: The destination `gdal.Group`.
            var_name: The name to create the array under.
            src_mdarray: The source `gdal.MDArray` to copy.
            dst_dims: The destination `gdal.Dimension` objects for `src_mdarray`'s axes, in
                order.
            dim_rename: `{old: new}` dimension renames this copy is part of, so a CF attribute
                naming a renamed dimension is rewritten rather than left pointing at a
                dimension the result no longer has (M4). `None` copies every value verbatim.
            derive_dtype_from_values: Take the destination's numeric dtype from the read-back
                array (`numpy_to_gdal_dtype`) instead of the source's `GetDataType()`. This is
                the one way :meth:`_add_md_array_to_group` differs, and it delegates here with
                the flag set so the two cannot drift apart: the store rebuild needs the stored
                dtype preserved, while the raster-level carry re-derives it from the values it
                read. Both bodies were otherwise identical.

        Returns:
            gdal.MDArray: The freshly written copy.

        Raises:
            RuntimeError: GDAL refuses the string write; its Python `Write` rejects every string
                array of rank >= 2.
            TypeError: The string source holds a NULL entry, which `Write` rejects.
            ValueError: The source is a compound, for which `numpy_to_gdal_dtype` has no GDAL
                type.
        """
        if src_mdarray.GetDataType().GetClass() == gdal.GEDTC_STRING:
            new_md_array = dst_group.CreateMDArray(
                var_name, dst_dims, gdal.ExtendedDataType.CreateString()
            )
            NetCDF._copy_md_array_labels(src_mdarray, new_md_array, dim_rename)
            try:
                new_md_array.Write(src_mdarray.Read())
            except (RuntimeError, TypeError, ValueError):
                # The array is created before it is written, and the write can fail: GDAL's
                # Python bindings refuse a string array of rank >= 2 (RuntimeError) and a NULL
                # entry reads back as `None`, which the write rejects (TypeError). Left in place,
                # the half-built array would list the variable with every value `None`.
                dst_group.DeleteMDArray(var_name)
                raise
        else:
            values = src_mdarray.ReadAsArray()
            dtype = (
                gdal.ExtendedDataType.Create(numpy_to_gdal_dtype(values))
                if derive_dtype_from_values
                else src_mdarray.GetDataType()
            )
            new_md_array = dst_group.CreateMDArray(var_name, dst_dims, dtype)
            # Packing before the data, so the netCDF driver accepts the fill.
            NetCDF._copy_md_array_packing(src_mdarray, new_md_array)
            NetCDF._copy_md_array_labels(src_mdarray, new_md_array, dim_rename)
            new_md_array.Write(values)
        return new_md_array

    @staticmethod
    def _bound_ancestor_chain(
        src_root: gdal.Group,
        dst_root: gdal.Group,
        group_path: str | None,
        remap: _DimensionRemap,
    ) -> tuple[gdal.Group, gdal.Group]:
        """Recreate the groups above the working group and bind each to its twin.

        A `get_group(...)` view's arrays reference dimensions — and their coordinate arrays —
        declared in an ancestor group. Recreating the chain gives those a destination group to
        be born in, which is what keeps the view's geotransform and its untouched axes' stamps
        (H1), and gives the rebuilt store the same shape the view came from so the result can be
        handed back as the equivalent view rather than a root container (L6).

        Args:
            src_root: The source store's root group.
            dst_root: The destination store's root group.
            group_path: The `/`-joined path of the working group, or `None` at the root.
            remap: The remap to bind each pair into.

        Returns:
            tuple[gdal.Group, gdal.Group]: The working group and its destination twin.
        """
        remap.bind(src_root, dst_root)
        NetCDF._copy_md_array_attributes(src_root, dst_root)
        src_group, dst_group = src_root, dst_root
        for part in group_path.split("/") if group_path else []:
            src_group = src_group.OpenGroup(part)
            dst_group = dst_group.CreateGroup(part)
            remap.bind(src_group, dst_group)
            NetCDF._copy_md_array_attributes(src_group, dst_group)
        return src_group, dst_group

    @staticmethod
    def _bound_sub_tree(
        src_group: gdal.Group,
        dst_group: gdal.Group,
        remap: _DimensionRemap,
        depth: int = 0,
    ) -> list[tuple[gdal.Group, gdal.Group]]:
        """The working group and every sub-group beneath it, each bound to a fresh twin.

        The rebuild used to read the working group's own arrays and nothing else, so a
        hierarchical container came back holding one variable out of twenty-nine with no warning
        and no error (C1). Each sub-group is recreated with its attributes, and its arrays are
        rebound through the same remap — which resolves a dimension declared in an ancestor from
        the array's own axis.

        Args:
            src_group: The source group, already bound to `dst_group`.
            dst_group: Its destination twin.
            remap: The remap to bind each created pair into.
            depth: Recursion depth, used by the recursion.

        Returns:
            list[tuple[gdal.Group, gdal.Group]]: `(source, destination)` pairs, the given group
                first, then its sub-groups depth-first.

        Warns:
            UserWarning: Nesting deeper than :data:`_MAX_GROUP_DEPTH` is not recreated, so the
                variables under those groups are absent from the result.
        """
        scope = [(src_group, dst_group)]
        sub_group_names = src_group.GetGroupNames() or []
        if depth < _MAX_GROUP_DEPTH:
            for group_name in sub_group_names:
                src_sub = src_group.OpenGroup(group_name)
                if src_sub is not None:
                    dst_sub = dst_group.CreateGroup(group_name)
                    remap.bind(src_sub, dst_sub)
                    NetCDF._copy_md_array_attributes(src_sub, dst_sub)
                    scope.extend(
                        NetCDF._bound_sub_tree(src_sub, dst_sub, remap, depth + 1)
                    )
        elif sub_group_names:
            skipped = ", ".join(sub_group_names)
            warnings.warn(
                f"Group nesting deeper than {_MAX_GROUP_DEPTH} is not rebuilt: the variables "
                f"under {skipped} are absent from the result of this rename/drop.",
                UserWarning,
                stacklevel=_caller_stacklevel(),
            )
        return scope

    def _rebuilt_container(
        self,
        *,
        rename: Mapping[str, str] | None = None,
        drop: Iterable[str] | None = None,
    ) -> NetCDF:
        """Rebuild this container into a fresh store with its axes renamed or pruned.

        The store-level engine behind the container paths of :meth:`rename_dims` and
        :meth:`drop_dims`. Every array — data variable, coordinate axis and CF bounds — in the
        working group **and in every sub-group beneath it** is recreated in a brand-new
        in-memory store by a packing-preserving copy (:meth:`_recreate_md_array`) bound to a
        transformed dimension set, and every group's attributes come across with it:

        * `rename` re-labels a dimension in place — the old dimension and its coordinate array
          are gone from the result (no orphan GDAL would otherwise leave behind, M2), the
          coordinate axis carries its own `units` onto the new name (so a non-time axis is
          never tagged with CF time units, M3), and the variable inventory is unchanged because
          every array, bounds included, is carried onto the renamed axis.
        * `drop` omits the named dimensions, their coordinate arrays and every array that spans
          them, keeping each survivor's exact dtype / packing / no-data (a `merge` rebuild would
          read and unpack them, M1).

        Dimensions are created **on demand**, from the axes of the arrays that survive, so the
        result declares no dimension nothing in it references (M1). On a `get_group(...)` view
        the ancestor chain is recreated too, so the view's inherited spatial axes keep their
        coordinate arrays — and the result is handed back as the equivalent group view rather
        than a root container.

        A rename and a drop are not combined in one call; passing both is refused.

        Args:
            rename: `{old: new}` dimension renames. A dimension not named is carried unchanged.
            drop: Dimension names to remove, with every array declared along them.

        Returns:
            NetCDF: A fresh `Container` with the transformed axes, or — when this container is
                itself a `get_group(...)` view — the equivalent view onto the rebuilt store.

        Raises:
            ValueError: Both `rename` and `drop` were passed. The two transforms are applied by
                different callers and their dimension bookkeeping differs, so one rebuild
                carries one of them.
        """
        if rename and drop:
            raise ValueError(
                "_rebuilt_container(): pass either rename= or drop=, not both; one rebuild "
                "applies one transform."
            )
        drop = set(drop or [])
        # Read straight from this container's own store. The destination is a brand-new
        # dataset, so nothing here writes to the source and the defensive full-store MEM
        # `CreateCopy` this used to take bought nothing -- it only doubled peak memory for an
        # operation that moves no cells (`rename`) or strictly reduces (`drop`), and made
        # `drop_dims` materialize the very arrays it was about to discard (M5).
        dst_ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("")
        remap = _DimensionRemap(
            dst_ds.GetRootGroup(), dict(rename or {}), drop, NetCDF._recreate_md_array
        )
        src_group, dst_group = NetCDF._bound_ancestor_chain(
            self._raster.GetRootGroup(),
            dst_ds.GetRootGroup(),
            self._group_path,
            remap,
        )
        scope = NetCDF._bound_sub_tree(src_group, dst_group, remap)
        # A coordinate array is created by the remap, when and only when its dimension is, so
        # the walk must not copy one as data as well -- and must not copy one whose dimension
        # never gets created at all.
        coordinates = remap.coordinate_paths([pair[0] for pair in scope])
        for src_sub, dst_sub in scope:
            for array_name in src_sub.GetMDArrayNames() or []:
                src_arr = src_sub.OpenMDArray(array_name)
                spans_dropped = {
                    axis.GetName() for axis in src_arr.GetDimensions()
                } & drop
                if not (spans_dropped or src_arr.GetFullName() in coordinates):
                    remap.recreate(dst_sub, array_name, src_arr)
        rebuilt = Container(dst_ds)
        return rebuilt if not self._group_path else rebuilt.get_group(self._group_path)

    @staticmethod
    def _get_or_create_dimension(
        rg: gdal.Group, dim_name: str, values: np.ndarray, dtype, dim_type=None
    ) -> gdal.Dimension:
        """Reuse an existing dimension or create a new one.

        A dimension with `dim_name` is reused only when it holds **the same coordinate
        values** in the same order, not merely the same number of them. Matching on the
        size alone silently mislabelled every variable written back on a reordered axis:
        `sortby("time", ascending=False)` followed by `set_variable` kept the store's own
        `[0, 6, 12, 18]`, so each plane landed on the wrong stamp. On any mismatch — a
        different length or different values — a suffixed dimension is created instead.

        Args:
            rg: The root group of the multidimensional dataset.
            dim_name: Name of the dimension (e.g., `"x"`, `"time"`).
            values: Coordinate values for this dimension.
            dtype: GDAL `ExtendedDataType` for the indexing variable.
            dim_type: GDAL dimension type constant (e.g.,
                `gdal.DIM_TYPE_HORIZONTAL_X`). Defaults to None.

        Returns:
            gdal.Dimension: The reused or newly created dimension.
        """
        existing = {
            dimension.GetName(): dimension for dimension in rg.GetDimensions() or []
        }
        wanted = dim_name
        reused = existing.get(dim_name)
        if reused is not None and not NetCDF._dimension_holds(reused, values):
            # A suffixed sibling may already hold exactly these coordinates: the same
            # reordered axis written twice must land on one dimension, not on `time_4` and
            # then `time_4_2`, which would leave a CF reader looking at two unrelated axes
            # carrying the same stamps.
            reused = next(
                (
                    sibling
                    for name, sibling in existing.items()
                    if name.startswith(f"{wanted}_")
                    and NetCDF._dimension_holds(sibling, values)
                ),
                None,
            )
            if reused is None:
                dim_name = NetCDF._unused_dimension_name(existing, wanted, len(values))
            written = reused.GetName() if reused is not None else dim_name
            warnings.warn(
                f"the axis holds different coordinate values from the store's own "
                f"{wanted!r}, which one netCDF dimension cannot carry at once, so it was "
                f"written as {written!r}. Select on the result under that name.",
                stacklevel=2,
            )
        return (
            reused
            if reused is not None
            else NetCDF.create_main_dimension(rg, dim_name, dtype, values)
        )

    @staticmethod
    def _coordinateless_dimension(
        rg: gdal.Group, dim_name: str, size: int, dim_type: str | None = None
    ) -> gdal.Dimension:
        """A band dimension with **no** indexing variable, for a coordinate-less axis.

        When an operator's operands disagree about their stamps, T20 keeps the dimension
        but no coordinates (`_band_dim_values_map[dim] is None`) — neither operand's stamps
        describe the result. Writing that axis must not invent any: not the store's own
        `time` (that is the axis the disagreement rejected), and not the positional
        `[0, 1, 2, …]` a `range(size)` fallback would fabricate. netCDF allows a dimension
        with no coordinate variable — a WRF `bottom_top` is one — and `_read_band_dim_values`
        reads that shape back as `None`, so the round trip preserves "no coordinates" (#1192).

        An existing coordinate-less dimension of the same name and size is reused, so
        rewriting the same variable does not accumulate siblings. A name already taken by a
        *coordinated* dimension (or a coordinate-less one of another size) cannot be reused —
        one netCDF dimension cannot be both — so a suffixed name is created instead, and a
        warning names it so a later selection is not a silent surprise.

        Args:
            rg: The root group.
            dim_name: The band dimension's name.
            size: Its length.
            dim_type: The GDAL dimension type (e.g. `gdal.DIM_TYPE_TEMPORAL`), or `None`.

        Returns:
            gdal.Dimension: The reused or newly created coordinate-less dimension.

        Warns:
            UserWarning: The name is already held by a dimension this axis cannot share, so
                the axis was written under a suffixed name to select on instead.
        """
        existing = {
            dimension.GetName(): dimension for dimension in rg.GetDimensions() or []
        }
        match = existing.get(dim_name)
        reusable = (
            match is not None
            and match.GetSize() == size
            and match.GetIndexingVariable() is None
        )
        if reusable:
            result = match
        else:
            target = (
                dim_name
                if match is None
                else NetCDF._unused_dimension_name(existing, dim_name, size)
            )
            if match is not None:
                # The store already holds a dimension of this name that a coordinate-less
                # axis cannot share — a coordinated one, or a coordinate-less one of another
                # size — so it is written under a suffixed name. Announce it, as
                # `_get_or_create_dimension` announces the analogous coordinated collision;
                # otherwise a later `isel(<name>=...)` fails with no hint the axis was renamed.
                warnings.warn(
                    f"the store already holds a dimension named {dim_name!r} that this "
                    f"coordinate-less axis cannot share, so it was written as {target!r}. "
                    "Select on the result under that name.",
                    stacklevel=2,
                )
            result = rg.CreateDimension(target, dim_type or "", None, size)
        return result

    @staticmethod
    def _same_grid(source: NetCDF, arr: np.ndarray, geo: tuple) -> bool:
        """Whether a rebuilt array still sits on `source`'s grid.

        Args:
            source: The variable the result derives from.
            arr: The result's cells; its trailing two axes are rows and columns.
            geo: The result's geotransform.

        Returns:
            bool: `True` when the shape and the transform are the source's, so the
            source's axis names describe the result as well.

        Notes:
            The transforms are compared at a tolerance, not for equality. Every caller
            today hands back the same memoised tuple it is compared against, but one that
            re-derives an identical grid — from the coordinate values, say — lands a float
            ulp away, and dropping the names over that is #1180 again. The slack has both
            a relative and an absolute part, as `_holds_the_same_grid_axis` does: a
            transform re-derived from float32 coordinates lands off by the coordinate's
            magnitude times the float32 epsilon (an origin near 90 by ~9e-6), which a
            cell-scaled `atol` alone would reject.
        """
        shaped = np.shape(arr)
        mine = tuple(float(one) for one in geo or ())
        theirs = tuple(float(one) for one in source.geotransform or ())
        same = (
            len(shaped) >= 2
            and (shaped[-2], shaped[-1]) == (source.rows, source.columns)
            and len(mine) == len(theirs)
        )
        if same and mine:
            cell = max(abs(theirs[1]), abs(theirs[5]))
            same = bool(np.allclose(mine, theirs, rtol=1e-6, atol=cell * 1e-6))
        return same

    @staticmethod
    def _matching_spatial_dimension(
        rg: gdal.Group, values: np.ndarray, dim_type
    ) -> gdal.Dimension | None:
        """The store's own axis for these coordinates, or `None` when it has none.

        A spatial dimension used to be resolved by **name**, always `"x"` or `"y"`, so
        writing a variable into a store whose axes are `longitude` / `latitude` left it
        with two pairs describing one grid and declared the new variable against the pair
        the store did not have (#1194). An axis is identified here by what it holds — the
        same coordinate values, in the same order — and by being the horizontal axis
        asked for, so a grid that genuinely differs still gets its own. `_spatial_axes`
        takes this decision for both axes together.

        Args:
            rg: The root group.
            values: The coordinate values wanted.
            dim_type: `gdal.DIM_TYPE_HORIZONTAL_X` or `..._Y`.

        Returns:
            gdal.Dimension | None: The matching dimension.
        """
        found = None
        for existing in rg.GetDimensions() or []:
            if existing.GetType() != dim_type:
                continue
            if NetCDF._holds_the_same_grid_axis(existing, values):
                found = existing
                break
        return found

    @staticmethod
    def _spatial_axes(
        rg: gdal.Group,
        x_values: np.ndarray,
        y_values: np.ndarray,
        dtype,
        names: tuple[str, str],
    ) -> tuple:
        """The `(column, row)` dimensions for a grid — the store's own pair, or a new one.

        The decision is taken for **both** axes together. Resolving them one at a time let a
        variable be declared against a pair drawn from two naming systems: on a y-ascending
        store, `set_variable`'s north-up row coordinates never match the store's ascending
        `lat` while its columns match `lon` exactly, so the result was `(y, lon)` with a
        stray `y` left in the store. Either the store describes this grid or it does not.

        Args:
            rg: The root group.
            x_values: The column coordinates.
            y_values: The row coordinates.
            dtype: The coordinate array's type.
            names: The `(row, column)` names to create under when the store has no pair.

        Returns:
            tuple: `(dim_x, dim_y)`.
        """
        row_name, column_name = names
        matched_x = NetCDF._matching_spatial_dimension(
            rg, x_values, gdal.DIM_TYPE_HORIZONTAL_X
        )
        matched_y = NetCDF._matching_spatial_dimension(
            rg, y_values, gdal.DIM_TYPE_HORIZONTAL_Y
        )
        if matched_x is not None and matched_y is not None:
            axes = (matched_x, matched_y)
        else:
            axes = (
                NetCDF._get_or_create_dimension(
                    rg, column_name, x_values, dtype, gdal.DIM_TYPE_HORIZONTAL_X
                ),
                NetCDF._get_or_create_dimension(
                    rg, row_name, y_values, dtype, gdal.DIM_TYPE_HORIZONTAL_Y
                ),
            )
        return axes

    @staticmethod
    def _holds_the_same_grid_axis(
        dimension: gdal.Dimension, values: np.ndarray
    ) -> bool:
        """Whether a spatial dimension carries the coordinates about to be written.

        Two differences from `_dimension_holds`, which answers the same question for a band
        axis and is deliberately left alone:

        - **A tolerance, not equality.** `set_variable` does not read the store's
          coordinates; it recomputes cell centres from the derived geotransform, and that
          round trip — stored values to `(origin, step)` and back — is bit-exact only on
          dyadic spacings. On an ordinary 0.1 degree grid, and on any float32 axis, the
          recomputed centres differ in the last few ulps, and matching on `==` meant the
          store silently gained a second pair of axes on almost every real file. The slack
          has **both** a relative and an absolute part: float32 quantization error grows
          with the coordinate's magnitude — a latitude near 50 is off by ~5e-6, far more
          than a step-scaled `atol` alone allows — so the relative `rtol` tracks the
          value while the `atol` floor covers an axis whose coordinates sit near zero.
        - **Coordinates are required.** `_dimension_holds` falls back to the size when a
          dimension has no indexing variable, which is right for a band axis that never had
          coordinates. Here it would let any coordinate-less horizontal axis of the same
          width — what a curvilinear or WRF store has — stand in for a grid it says nothing
          about.

        Args:
            dimension: A candidate dimension of the wanted horizontal type.
            values: The coordinate values about to be written.

        Returns:
            bool: `True` when the dimension carries these coordinates.
        """
        stored = (
            NetCDF._read_band_dim_values(dimension)
            if dimension.GetIndexingVariable() is not None
            else None
        )
        wanted = np.asarray(values, dtype="float64").ravel()
        same = stored is not None and len(stored) == len(wanted)
        if same:
            held = np.asarray(stored, dtype="float64").ravel()
            # Scaled to the axis' own step, so the slack is a fraction of a cell rather
            # than an absolute distance that would mean one thing in degrees and another
            # in metres.
            step = float(np.abs(np.diff(held)).max()) if len(held) > 1 else 1.0
            same = bool(
                np.allclose(
                    held, wanted, rtol=1e-6, atol=abs(step) * 1e-6, equal_nan=True
                )
            )
        return same

    @staticmethod
    def _dimension_holds(dimension: gdal.Dimension, values: np.ndarray) -> bool:
        """Whether an existing dimension already carries exactly `values`, in order.

        A dimension with no indexing variable carries no values to disagree with, so it
        matches on size alone — that is the axis `isel` exists to serve, and a WRF
        `bottom_top` is one.

        Args:
            dimension: The dimension found under the wanted name.
            values: The coordinate values about to be written.

        Returns:
            bool: `True` when the dimension can be reused as it stands.
        """
        holds = bool(dimension.GetSize() == len(values))
        if holds:
            stored = NetCDF._read_band_dim_values(dimension)
            if stored is not None:
                wanted = list(np.asarray(values).ravel().tolist())
                holds = len(stored) == len(wanted) and all(
                    NetCDF._same_stamp(one, other) for one, other in zip(stored, wanted)
                )
        return holds

    @staticmethod
    def _same_stamp(one: Any, other: Any) -> bool:
        """Whether two coordinate values stand for the same step.

        A NaN stamp equals nothing, itself included, so the two are compared for
        not-a-number rather than for equality when either is one: two axes that both carry
        a NaN at the same position do agree about that position.

        Args:
            one: The stored stamp.
            other: The stamp about to be written.

        Returns:
            bool: `True` when they are the same step.
        """
        same = bool(one == other)
        if not same and isinstance(one, float) and isinstance(other, float):
            same = math.isnan(one) and math.isnan(other)
        return same

    @staticmethod
    def _unused_dimension_name(
        existing: dict[str, Any], dim_name: str, size: int
    ) -> str:
        """A dimension name nothing in the group has taken yet.

        `<name>_<size>` first, which is what a length mismatch has always produced, then
        `<name>_<size>_2`, `_3` and so on — a reordered axis has the *same* length as the
        one it clashes with, so the plain suffix can be taken as well.

        Args:
            existing: The group's dimensions, by name.
            dim_name: The wanted name.
            size: How many values the new dimension holds.

        Returns:
            str: A free name.
        """
        candidate = f"{dim_name}_{size}"
        attempt = 2
        while candidate in existing:
            candidate = f"{dim_name}_{size}_{attempt}"
            attempt += 1
        return candidate

    @property
    def global_attributes(self) -> dict[str, Any]:
        """Global attributes from the root group.

        Returns a live dict read from the GDAL root group each time.
        For MDIM mode, reads from the root group's attributes.
        For classic mode, reads from GDAL's `GetMetadata()`.

        Returns:
            dict[str, Any]: Key-value mapping of global attributes.
        """
        rg = self._working_group()
        if rg is not None:
            result = _read_attributes(rg)
        else:
            result = dict(self._raster.GetMetadata())
        return result

    def set_global_attribute(self, name: str, value: Any):
        """Set a global attribute on the root group.

        Creates or updates a single attribute on the root group.

        Args:
            name: Attribute name (e.g. `"history"`,
                `"Conventions"`).
            value: Attribute value. Supports str, bool, int, float, and a numeric
                `list`/`tuple` — the latter is written as a real CF vector attribute
                (`Float64[n]`), not as its Python repr.

        Raises:
            ValueError: If the dataset has no root group
                (not opened in MDIM mode).
        """
        rg = self._working_group()
        if rg is None:
            raise ValueError(
                "set_global_attribute requires a multidimensional "
                "container. Open the file with "
                "open_as_multi_dimensional=True."
            )
        # Through the shared `cf` dispatch so a numeric sequence becomes a CF vector
        # attribute. `overwrite=True` keeps this member an upsert (GDAL refuses a duplicate,
        # so the attribute is deleted first).
        write_single_attr(rg, name, value, overwrite=True)
        self._invalidate_caches()

    def delete_global_attribute(self, name: str):
        """Delete a global attribute from the root group.

        If the attribute does not exist, the call is silently ignored.

        Args:
            name: Attribute name to delete.

        Raises:
            ValueError: If the dataset has no root group.
        """
        rg = self._working_group()
        if rg is None:
            raise ValueError(
                "delete_global_attribute requires a multidimensional container."
            )
        try:
            rg.DeleteAttribute(name)
        except RuntimeError:
            pass  # attribute may not exist — silently ignored
        self._invalidate_caches()

    def set_variable(self, *args, **kwargs):
        """Facade — :meth:`Variables.set_variable <pyramids.netcdf.engines.variables.Variables.set_variable>`."""
        return self.varops.set_variable(*args, **kwargs)

    def crop_variable(
        self, variable_name: str, mask: Any, touch: bool = True
    ) -> NetCDF:
        """Crop a single variable and store the result back.

        Convenience method that combines `get_variable` → `crop`
        → `set_variable` in one call.

        Args:
            variable_name: Name of the variable to crop.
            mask: GeoDataFrame with polygon geometry, or a Dataset
                to use as a spatial mask.
            touch: If True, include cells touching the mask boundary.
                Defaults to True.

        Returns:
            NetCDF: This container (modified in-place).
        """
        var = self._require_raster_variable(variable_name)
        cropped = var.crop(mask, touch=touch)
        self.set_variable(variable_name, cropped)
        return self

    def reproject_variable(
        self, variable_name: str, to_epsg: int, method: str = DEFAULT_RESAMPLING
    ) -> NetCDF:
        """Reproject a single variable and store the result back.

        Convenience method that combines `get_variable` → `to_crs`
        → `set_variable` in one call.

        The warp moves the variable's stored values, and the rebuilt variable
        is written back with the same CF packing (`scale_factor` /
        `add_offset`), so a packed variable stays packed at its stored dtype
        and still reads back in physical units.

        Args:
            variable_name: Name of the variable to reproject.
            to_epsg: Target EPSG code (e.g. 4326, 32637).
            method: Resampling method. Defaults to
                `"nearest neighbor"`.

        Returns:
            NetCDF: This container (modified in-place).

        Raises:
            CRSError: The variable has no CRS to reproject from.

        Examples:
            - Reproject a packed variable; it keeps its packing and its units:
                ```python
                >>> import numpy as np
                >>> from pyramids.dataset import Dataset, GeoReference
                >>> from pyramids.netcdf import NetCDF
                >>> geo_ref = GeoReference(top_left_corner=(0, 1), cell_size=0.1, epsg=4326)
                >>> nc = NetCDF.from_array(
                ...     arr=np.zeros((10, 10), dtype="int16"), geo_ref=geo_ref, variable_name="t"
                ... )
                >>> packed = Dataset.from_array(np.full((10, 10), 100, dtype="int16"), geo_ref=geo_ref)
                >>> packed.scale = [0.01]
                >>> nc.set_variable("t", packed)
                >>> _ = nc.reproject_variable("t", 3857)
                >>> t = nc.get_variable("t")
                >>> t.epsg, t.read_array(unpack=False).dtype
                (3857, dtype('int16'))
                >>> float(t.read_array(masked=True).max())
                1.0

                ```
        """
        var = self._require_raster_variable(variable_name)
        reprojected = var.to_crs(to_epsg, method=method)
        # to_crs returns a VRT-backed dataset — materialize it into
        # a MEM dataset so the data survives after the VRT source
        # (the variable subset) is garbage collected.
        #
        # `unpack=False`: the materialize is a copy of the warped store, and the
        # packing is carried onto it below, so `set_variable` writes the counts that
        # recipe describes rather than a `float64` widening of them. `squeeze=True` keeps the
        # classic flattened `(bands, rows, cols)` `from_array` and the `set_variable` below
        # expect, not the dimension-preserving default (#1241).
        arr = reprojected.read_array(unpack=False, squeeze=True)
        no_data_value = reprojected.no_data_value
        ndv_scalar = (
            no_data_value[0]
            if isinstance(no_data_value, (list, tuple)) and no_data_value
            else no_data_value
        )
        # epsg is None only for a no-EPSG CRS reported as such (a NetCDF
        # geostationary grid), and from_array raises CRSError on None, so fall
        # back to the WKT (#706). to_crs(to_epsg: int, ...) always targets a
        # concrete EPSG here, so epsg is provably non-None -- the fallback is
        # defense-in-depth, matching the pattern used throughout pyramids.dataset.
        materialized = Dataset.from_array(
            cast("np.typing.NDArray", arr),
            geo_ref=GeoReference(
                geo=reprojected.geotransform,
                epsg=crs_spec(reprojected.epsg, reprojected.crs),
            ),
            no_data_value=ndv_scalar,
        )
        carry_packing(reprojected.raster, materialized.raster)
        NetCDF._copy_band_dim_metadata(materialized, var)
        materialized._variable_attrs = var._variable_attrs
        self.set_variable(variable_name, materialized)
        return self

    def resample_variable(
        self,
        variable_name: str,
        cell_size: int | float,
        method: str = DEFAULT_RESAMPLING,
    ) -> NetCDF:
        """Resample a single variable and store the result back.

        Convenience method that combines `get_variable` → `resample`
        → `set_variable` in one call.

        Args:
            variable_name: Name of the variable to resample.
            cell_size: New cell size.
            method: Resampling method. Defaults to
                `"nearest neighbor"`.

        Returns:
            NetCDF: This container (modified in-place).
        """
        var = self._require_raster_variable(variable_name)
        resampled = var.resample(cell_size, method=method)
        self.set_variable(variable_name, resampled)
        return self

    def add_variable(self, *args, **kwargs):
        """Facade — :meth:`Variables.add_variable <pyramids.netcdf.engines.variables.Variables.add_variable>`."""
        return self.varops.add_variable(*args, **kwargs)

    def remove_variable(self, *args, **kwargs):
        """Facade — :meth:`remove_variable <pyramids.netcdf.engines.variables.Variables.remove_variable>`."""
        return self.varops.remove_variable(*args, **kwargs)

    def rename_variable(self, *args, **kwargs):
        """Facade — :meth:`rename_variable <pyramids.netcdf.engines.variables.Variables.rename_variable>`."""
        return self.varops.rename_variable(*args, **kwargs)

    def to_xarray(
        self, chunks: dict | str | int | None = None, *, decode_times: bool = True
    ) -> Any:
        """Facade — delegates to :meth:`Interop.to_xarray <pyramids.netcdf.engines.interop.Interop.to_xarray>`.

        Spelled out rather than `*args, **kwargs` so the keywords are visible where
        callers look for them: the rendered reference, `help(NetCDF.to_xarray)` and
        editor completion all read the facade, not the engine behind it.

        Args:
            chunks: Dask chunking for a lazy read, or `None` to read eagerly.
            decode_times: Whether to decode a CF time axis to `datetime64[ns]`. `False`
                returns the stored offsets.

        Returns:
            xarray.Dataset: The exported cube. See the engine method for the full
            contract.

        Raises:
            pyramids.base._errors.OptionalPackageDoesNotExist: `xarray` is not installed.
            ImportError: `chunks` was given but the `[lazy]` (dask) extra is not installed.
            ValueError: This container is not multidimensional — open the file with
                `open_as_multi_dimensional=True`.

        Warns:
            TimeDecodingWarning: A dimension declares CF time `units` that could not be
                decoded, so it was exported as its stored offsets.

        Examples:
            - Export a CF container and group its cube by a time component:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> xds = nc.to_xarray()
              >>> str(xds.coords["time"].dtype)
              'datetime64[ns]'
              >>> xds["temperature"].groupby("time.month").mean().shape
              (1, 3, 5, 6)

              ```
            - Keep the axis as the offsets the file stores:

              ```python
              >>> from pyramids.netcdf import NetCDF
              >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
              >>> raw = nc.to_xarray(decode_times=False)
              >>> [float(offset) for offset in raw.coords["time"].values]
              [0.0, 6.0, 12.0, 18.0]

              ```

        See Also:
            NetCDF.from_xarray: The inverse, which writes a decoded axis back in the
                CF units this put in its `encoding`.
        """
        return self.interop.to_xarray(chunks, decode_times=decode_times)

    def subset(self, *args, **kwargs) -> NetCDF:
        """Facade — :meth:`Selection.subset <pyramids.netcdf.engines.selection.Selection.subset>`."""
        return self.selection.subset(*args, **kwargs)

    @staticmethod
    def _plan_band_slices(
        dim_names: list[str],
        dim_sizes: list[int],
        x_axis: int,
        y_axis: int,
        time_axis: int | None,
        x_window: tuple[int, int],
        y_window: tuple[int, int],
        time: Any,
        dims: dict[str, Any],
    ) -> tuple[list[slice], list[tuple[str, range]]]:
        """Per-dimension read slices + the ranged non-spatial axes (the band axes).

        Spatial axes take the bbox windows; every other axis is pinned to one
        index (or a range) via :func:`_resolve_index_selector`. A name in ``dims``
        wins for its axis; otherwise the time axis uses ``time``.
        """
        slices: list[slice] = []
        ranged_axes: list[tuple[str, range]] = []
        for axis, (name, size) in enumerate(zip(dim_names, dim_sizes)):
            if axis == x_axis:
                slices.append(slice(*x_window))
            elif axis == y_axis:
                slices.append(slice(*y_window))
            else:
                if name in dims:
                    selector = dims[name]
                elif axis == time_axis:
                    selector = time
                else:
                    selector = None
                start, stop = _resolve_index_selector(selector, size, name)
                slices.append(slice(start, stop))
                if stop - start > 1:
                    ranged_axes.append((name, range(start, stop)))
        return slices, ranged_axes

    @staticmethod
    def _band_labels(ranged_axes: list[tuple[str, range]]) -> list[str]:
        """Cartesian-product band labels for the ranged axes (C-order, last fastest)."""
        if not ranged_axes:
            return []
        return [
            ",".join(f"{nm}={i}" for (nm, _), i in zip(ranged_axes, combo))
            for combo in itertools.product(*(idxs for _, idxs in ranged_axes))
        ]

    @staticmethod
    def _north_up_geobox(
        arr: np.ndarray,
        x_coords: np.ndarray,
        y_coords: np.ndarray,
        x_window: tuple[int, int],
        y_window: tuple[int, int],
    ) -> tuple[np.typing.NDArray, tuple[float, float, float, float, float, float]]:
        """Flip ``arr`` to north-up / west-to-east; return ``(arr, geotransform)``.

        Cell size comes from the full coordinate spacing so a 1-cell-wide window
        still carries the store's true resolution.
        """
        x_vals = x_coords[x_window[0] : x_window[1]]
        y_vals = y_coords[y_window[0] : y_window[1]]
        if x_vals.size > 1 and x_vals[0] > x_vals[-1]:
            arr = arr[:, :, ::-1]
            x_vals = x_vals[::-1]
        if y_vals.size > 1 and y_vals[0] < y_vals[-1]:
            arr = arr[:, ::-1, :]
            y_vals = y_vals[::-1]
        d_x = abs(x_coords[1] - x_coords[0]) if x_coords.size > 1 else 1.0
        d_y = abs(y_coords[1] - y_coords[0]) if y_coords.size > 1 else d_x
        geo = (
            float(x_vals[0] - d_x / 2.0),
            float(d_x),
            0.0,
            float(y_vals[0] + d_y / 2.0),
            0.0,
            -float(d_y),
        )
        return arr, geo

    @staticmethod
    def _assert_full_rank(arr: np.ndarray, n_dims: int, variable: str) -> None:
        """Assert a windowed read kept one axis per dimension (incl. size-1 ones).

        ``subset`` locates the spatial axes by their dimension index, so the array
        returned by the windowed read must have exactly ``n_dims`` axes; a future
        GDAL that squeezed singleton axes would invalidate those indices.

        Args:
            arr: The array returned by the windowed MDArray read.
            n_dims: The variable's dimension count.
            variable: Variable name, for the error message.

        Raises:
            RuntimeError: when ``arr.ndim != n_dims``.

        Examples:
            - A matching rank is accepted (returns ``None``)::

                >>> import numpy as np
                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._assert_full_rank(np.zeros((1, 4, 5)), 3, "soil") is None
                True

            - A squeezed read is rejected::

                >>> import numpy as np
                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._assert_full_rank(np.zeros((4, 5)), 3, "soil")
                Traceback (most recent call last):
                    ...
                RuntimeError: windowed read of 'soil' returned 2 axes, expected 3; cannot locate the spatial axes.
        """
        if arr.ndim != n_dims:
            raise RuntimeError(
                f"windowed read of {variable!r} returned {arr.ndim} axes, expected "
                f"{n_dims}; cannot locate the spatial axes."
            )

    @staticmethod
    def _axis_role(rg: Any, name: str) -> str | None:
        """CF spatial role of a coordinate variable: ``"Y"``, ``"X"``, or ``None``.

        Inspects the coordinate array's ``axis`` / ``standard_name`` / ``units``
        attributes. Returns ``None`` when the dimension has no coordinate array
        or no recognisable spatial attribute.

        Args:
            rg: The store's multidimensional root :class:`osgeo.gdal.Group`.
            name: Dimension / coordinate name to inspect.

        Returns:
            str or None: ``"Y"`` for a latitude / northing axis, ``"X"`` for a
            longitude / easting axis, or ``None`` when the role can't be
            determined.

        Examples:
            - Inspect a latitude coordinate of an opened multidim store::

                >>> nc._axis_role(nc._raster.GetRootGroup(), "lat")  # doctest: +SKIP
                'Y'
        """
        coord = open_mdarray(rg, name)
        if coord is None:
            return None
        # Attribute-only detection (empty ``name`` disables the name-pattern fallback): the
        # name-based stage lives in ``_named_spatial_axes`` and must stay separate so the
        # CF-attribute pass keeps precedence over it.
        role = detect_axis("", read_cf_attributes(coord))
        return role if role in ("X", "Y") else None

    @staticmethod
    def _detect_spatial_axes(
        rg: Any,
        dim_names: list[str],
        y_dim: str | None,
        x_dim: str | None,
    ) -> tuple[int, int]:
        """Resolve the ``(y_axis, x_axis)`` indices of a gridded variable.

        Resolution order: an explicit ``y_dim`` / ``x_dim`` override → CF axis /
        ``standard_name`` / ``units`` attributes on the coordinate variables →
        well-known dimension names (``y``/``lat``/…, ``x``/``lon``/…) → the two
        trailing dimensions (the common ``(…, y, x)`` layout).

        Args:
            rg: The multidimensional root group (``None`` skips CF-attribute
                detection — used by the pure name/trailing fallbacks).
            dim_names: The variable's dimension names, in storage order.
            y_dim: Optional explicit name of the ``y`` dimension.
            x_dim: Optional explicit name of the ``x`` dimension.

        Returns:
            tuple[int, int]: The ``(y_axis, x_axis)`` indices into ``dim_names``.

        Raises:
            ValueError: if only one of ``y_dim`` / ``x_dim`` is given, the two are
                equal, or a named override is not a dimension of the variable.

        Examples:
            - A layer dim interleaved between ``y`` and ``x`` is still resolved
              by name (``rg=None`` skips the CF-attribute step)::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._detect_spatial_axes(None, ["time", "y", "level", "x"], None, None)
                (1, 3)

            - An explicit override wins regardless of position::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._detect_spatial_axes(None, ["time", "y", "x"], "y", "x")
                (1, 2)
        """
        override = NetCDF._override_spatial_axes(dim_names, y_dim, x_dim)
        if override is not None:
            return override
        return (
            NetCDF._cf_spatial_axes(rg, dim_names)
            or NetCDF._named_spatial_axes(dim_names)
            or (len(dim_names) - 2, len(dim_names) - 1)
        )

    @staticmethod
    def _override_spatial_axes(
        dim_names: list[str], y_dim: str | None, x_dim: str | None
    ) -> tuple[int, int] | None:
        """Validate and apply an explicit ``y_dim`` / ``x_dim`` override.

        Args:
            dim_names: The variable's dimension names, in storage order.
            y_dim: Explicit ``y`` dimension name, or ``None``.
            x_dim: Explicit ``x`` dimension name, or ``None``.

        Returns:
            tuple[int, int] or None: The ``(y_axis, x_axis)`` indices when both
            names are given; ``None`` when neither is (so auto-detection
            proceeds).

        Raises:
            ValueError: when exactly one name is given, the two are equal, or a
                name is not a dimension of the variable.

        Examples:
            - Both names resolve to their indices::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._override_spatial_axes(["time", "y", "x"], "y", "x")
                (1, 2)

            - Neither name defers to auto-detection (returns ``None``)::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._override_spatial_axes(["y", "x"], None, None) is None
                True
        """
        if y_dim is None and x_dim is None:
            return None
        if (y_dim is None) != (x_dim is None):
            raise ValueError("pass both y_dim and x_dim, or neither.")
        if y_dim == x_dim:
            raise ValueError(f"y_dim and x_dim must differ; both are {y_dim!r}.")
        for value, label in ((y_dim, "y_dim"), (x_dim, "x_dim")):
            if value not in dim_names:
                raise ValueError(
                    f"{label}={value!r} is not a dimension of this variable; "
                    f"available: {dim_names}"
                )
        return dim_names.index(cast("str", y_dim)), dim_names.index(cast("str", x_dim))

    @staticmethod
    def _cf_spatial_axes(rg: Any, dim_names: list[str]) -> tuple[int, int] | None:
        """``(y_axis, x_axis)`` from CF coordinate attributes, or ``None``.

        Returns ``None`` when there is no root group or the coordinate variables
        don't carry recognisable spatial attributes for both axes.

        Args:
            rg: The multidimensional root group, or ``None``.
            dim_names: The variable's dimension names, in storage order.

        Returns:
            tuple[int, int] or None: The ``(y_axis, x_axis)`` indices when both
            spatial axes are identified from coordinate attributes, else ``None``.

        Examples:
            - Without a root group there are no attributes to read::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._cf_spatial_axes(None, ["time", "y", "x"]) is None
                True
        """
        if rg is None:
            return None
        y_idx = x_idx = None
        for axis, name in enumerate(dim_names):
            role = NetCDF._axis_role(rg, name)
            if role == "Y" and y_idx is None:
                y_idx = axis
            elif role == "X" and x_idx is None:
                x_idx = axis
            if y_idx is not None and x_idx is not None:
                return y_idx, x_idx
        return None

    @staticmethod
    def _named_spatial_axes(dim_names: list[str]) -> tuple[int, int] | None:
        """``(y_axis, x_axis)`` from well-known dimension names, or ``None``.

        Matches each dimension name case-insensitively against
        :data:`_Y_DIM_NAMES` / :data:`_X_DIM_NAMES`.

        Args:
            dim_names: The variable's dimension names, in storage order.

        Returns:
            tuple[int, int] or None: The ``(y_axis, x_axis)`` indices when both a
            y-like and an x-like name are present, else ``None``.

        Examples:
            - Recognised names resolve even when interleaved::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._named_spatial_axes(["time", "y", "soil", "x"])
                (1, 3)

            - Unrecognised names return ``None``::

                >>> from pyramids.netcdf import NetCDF
                >>> NetCDF._named_spatial_axes(["time", "a", "b"]) is None
                True
        """
        lowered = [n.lower() for n in dim_names]
        y_named = next((i for i, n in enumerate(lowered) if n in _Y_DIM_NAMES), None)
        x_named = next((i for i, n in enumerate(lowered) if n in _X_DIM_NAMES), None)
        if y_named is not None and x_named is not None:
            return y_named, x_named
        return None

    @staticmethod
    def _read_axis_coords(rg: Any, name: str, axis_label: str) -> np.typing.NDArray:
        """Read a spatial axis's 1-D coordinate values, or raise a clear error.

        A gridded variable can name a spatial dimension that has no indexing
        (coordinate) variable — e.g. WRF ``south_north`` / ``west_east``.
        ``OpenMDArray`` then returns ``None`` (or raises), so guard it with an
        actionable message instead of an opaque ``AttributeError``.

        Args:
            rg: The store's root :class:`osgeo.gdal.Group`.
            name: Coordinate (indexing) array name — the spatial dimension name.
            axis_label: ``"x"`` / ``"y"``, used in the error message.

        Returns:
            The coordinate values as a 1-D ``float64`` :class:`numpy.ndarray`.

        Raises:
            ValueError: When the dimension has no readable 1-D coordinate array.
        """
        coord = open_mdarray(rg, name)
        values = None if coord is None else coord.ReadAsArray()
        if values is None:
            raise ValueError(
                f"the {axis_label} dimension {name!r} has no 1-D coordinate "
                "variable; subset() needs x/y coordinates to window and "
                "georeference the grid."
            )
        return np.asarray(values, dtype="float64")

    @staticmethod
    def _detect_time_axis(dim_names: list[str], y_axis: int, x_axis: int) -> int | None:
        """Index of the time dimension, or the first non-spatial axis as fallback.

        The ``time=`` selector applies to this axis; any other non-spatial axis
        is selected through ``**dims`` by name.

        Args:
            dim_names: Dimension names in storage order.
            y_axis: Index of the ``y`` (row) axis to exclude.
            x_axis: Index of the ``x`` (column) axis to exclude.

        Returns:
            The time axis index, the first non-spatial axis if no dimension is
            time-like, or ``None`` when the variable is purely ``(y, x)``.

        Examples:
            - A dimension named ``"time"`` is the time axis:
                ```python
                >>> NetCDF._detect_time_axis(["time", "y", "x"], 1, 2)
                0

                ```
            - With no time-like name, the first non-spatial axis is used:
                ```python
                >>> NetCDF._detect_time_axis(["level", "member", "y", "x"], 2, 3)
                0

                ```
            - A purely 2-D ``(y, x)`` variable has no time axis:
                ```python
                >>> NetCDF._detect_time_axis(["y", "x"], 0, 1) is None
                True

                ```
        """
        time_like = {"time", "valid_time", "forecast_reference_time", "t"}
        fallback = None
        result = None
        for axis, name in enumerate(dim_names):
            if axis in (y_axis, x_axis):
                continue
            if fallback is None:
                fallback = axis
            if name.lower() in time_like:
                result = axis
                break
        return result if result is not None else fallback

    @staticmethod
    def _md_array_no_data(md_arr: Any) -> float | None:
        """Native no-data for an MDArray — driver value first, then CF attrs.

        Args:
            md_arr: The :class:`osgeo.gdal.MDArray` to inspect.

        Returns:
            The no-data value as a ``float``: the driver's value if set, else the
            ``missing_value``, ``_FillValue``, then ``nodata`` CF attribute, else
            ``None``. ``nodata`` is the attribute :meth:`DatasetCollection.to_netcdf`
            writes, since GDAL's multidim writer rejects ``_FillValue``.
        """
        result = md_arr.GetNoDataValueAsDouble()
        if result is None:
            for attr_name in ("missing_value", "_FillValue", "nodata"):
                try:
                    attr = md_arr.GetAttribute(attr_name)
                except RuntimeError:
                    attr = None
                if attr is not None:
                    result = float(np.asarray(attr.ReadAsDoubleArray()).ravel()[0])
                    break
        return cast("float | None", result)

    @staticmethod
    def _reproject_bbox_envelope(
        bbox: tuple[float, float, float, float],
        src_crs: int | str,
        dst_srs: Any,
        densify: int,
    ) -> tuple[float, float, float, float]:
        """Reproject a bbox into the store's native CRS and return its envelope.

        Densifies each edge before transforming so the projected envelope
        encloses the curved boundary of a lon/lat box on a projected grid
        (conservative over-cover). When the destination has no CRS, or matches
        the source, the bbox is treated as already native.

        Args:
            bbox: ``(min_x, min_y, max_x, max_y)`` in the source CRS.
            src_crs: Source CRS — an EPSG ``int`` or a WKT/PROJ/``"EPSG:n"`` string.
            dst_srs: Destination :class:`osgeo.osr.SpatialReference`, or ``None``
                when the store has no grid mapping.
            densify: Number of points sampled along each bbox edge (``>= 2``).

        Returns:
            The ``(min_x, min_y, max_x, max_y)`` envelope in the destination CRS.

        Examples:
            - With no destination CRS the bbox is returned unchanged (treated as
              already native):
                ```python
                >>> NetCDF._reproject_bbox_envelope((-1.0, -2.0, 3.0, 4.0), 4326, None, 5)
                (-1.0, -2.0, 3.0, 4.0)

                ```
        """
        min_x, min_y, max_x, max_y = (float(v) for v in bbox)
        # `sr_from_user_input` handles the int-vs-string spelling and stamps
        # traditional axis order itself, so the restamp below it was redundant.
        # It also resolves a CRS whose code only GDAL's PROJ database carries
        # (#943), and raises CRSError where the raw call raised RuntimeError.
        src = sr_from_user_input(src_crs)
        # Normalise the destination *before* the identity check, not after.
        # `IsSame` is axis-mapping sensitive for a geographic CRS
        # (`IGNORE_DATA_AXIS_TO_SRS_AXIS_MAPPING` defaults to `NO`), and `src`
        # comes out of `sr_from_user_input` stamped traditional while a
        # `GetSpatialRef()` off the store carries authority order -- so EPSG:4326
        # against EPSG:4326 compared *unequal* and took the densified
        # round-trip. Comparing the clone answers that in GDAL rather than
        # through pyproj: `crs_equal` re-parses both sides with
        # `pyproj.CRS.from_user_input` and returns False -- not "unknown" -- for
        # a CRS GDAL accepts and pyproj refuses, so such a CRS would compare
        # unequal to itself. Same reasoning as the revert in
        # `dataset/engines/spatial.py`, with the normalisation that revert
        # relies on made explicit here.
        dst = dst_srs.Clone() if dst_srs is not None else None
        if dst is not None:
            dst.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
        if dst is None or src.IsSame(dst):
            return min_x, min_y, max_x, max_y
        transform = osr.CoordinateTransformation(src, dst)
        edge = np.linspace(0.0, 1.0, max(int(densify), 2))
        xs = np.concatenate(
            [
                min_x + edge * (max_x - min_x),
                min_x + edge * (max_x - min_x),
                np.full_like(edge, min_x),
                np.full_like(edge, max_x),
            ]
        )
        ys = np.concatenate(
            [
                np.full_like(edge, min_y),
                np.full_like(edge, max_y),
                min_y + edge * (max_y - min_y),
                min_y + edge * (max_y - min_y),
            ]
        )
        proj_x: list[float] = []
        proj_y: list[float] = []
        for px, py in zip(xs, ys):
            tx, ty, _ = transform.TransformPoint(float(px), float(py))
            proj_x.append(tx)
            proj_y.append(ty)
        return min(proj_x), min(proj_y), max(proj_x), max(proj_y)

    @classmethod
    def from_xarray(
        cls,
        dataset: Any,
        path: str | Path | None = None,
    ) -> NetCDF:
        """Facade — delegates to :func:`pyramids.netcdf.engines.interop.from_xarray`.

        See that function for the full contract. ``from_xarray`` builds a new
        container (it does not operate on an existing instance), so its body
        lives in the module-level engine function rather than on the
        instance-bound :class:`~pyramids.netcdf.engines.interop.Interop` engine;
        ``cls`` is threaded through so the file is read back as the concrete
        ``Container`` subtype.
        """
        return _interop.from_xarray(cls, dataset, path)

top_left_corner property #

Top left corner coordinates.

lon property #

Longitude / x-coordinate values as a 1D array.

Looks for a variable named "lon", then "longitude", then "x" — the three spellings cf.build_coordinate_attrs already treats as the same axis. Knowing only lon and x meant a store that spells it longitude — which every CF file this package reads may — found nothing here, and _compute_geotransform fell back to GDAL's index-space placeholder, so the file reported pixel indices as world coordinates.

Returns:

Type Description
NDArray

np.ndarray: The stored coordinates, flattened. When the store spells the axis

NDArray

none of those three ways it is not an error and not None: the axis derived

NDArray

from the geotransform is returned instead, exactly as

NDArray

attr:pyramids.dataset.Dataset.lon builds it.

Examples:

  • A store that names its column axis longitude reports its own coordinates, which is what spatial_names puts there:
    >>> import numpy as np
    >>> from pyramids.netcdf import GeoReference, NetCDF
    >>> named = NetCDF.from_array(
    ...     np.zeros((2, 2)),
    ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
    ...     variable_name="t",
    ...     spatial_names=("latitude", "longitude"),
    ... )
    >>> sorted(named.dimension_names)
    ['latitude', 'longitude']
    >>> named.lon.tolist()
    [0.5, 1.5]
    
  • The default y / x naming reaches the same values through the third spelling, so both stores answer alike:
    >>> import numpy as np
    >>> from pyramids.netcdf import GeoReference, NetCDF
    >>> plain = NetCDF.from_array(
    ...     np.zeros((2, 2)),
    ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
    ...     variable_name="t",
    ... )
    >>> sorted(plain.dimension_names)
    ['x', 'y']
    >>> plain.lon.tolist()
    [0.5, 1.5]
    
See Also
  • :attr:lat: The same three spellings on the row axis.

lat property #

Latitude / y-coordinate values as a 1D array.

Looks for a variable named "lat", then "latitude", then "y" — the spellings :attr:lon accepts, on the other axis.

Returns:

Type Description
NDArray

np.ndarray: The stored coordinates, flattened. When the store spells the axis

NDArray

none of those three ways the geotransform-derived axis is returned instead,

NDArray

never None — see :attr:lon.

Examples:

  • A store that names its row axis latitude reports its own coordinates, and a north-up grid's rows descend from north to south:
    >>> import numpy as np
    >>> from pyramids.netcdf import GeoReference, NetCDF
    >>> named = NetCDF.from_array(
    ...     np.zeros((2, 2)),
    ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
    ...     variable_name="t",
    ...     spatial_names=("latitude", "longitude"),
    ... )
    >>> named.lat.tolist()
    [1.5, 0.5]
    
  • The default y / x naming answers with the same values:
    >>> import numpy as np
    >>> from pyramids.netcdf import GeoReference, NetCDF
    >>> plain = NetCDF.from_array(
    ...     np.zeros((2, 2)),
    ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
    ...     variable_name="t",
    ... )
    >>> plain.lat.tolist()
    [1.5, 0.5]
    
See Also
  • :attr:lon: The same three spellings on the column axis.

x property #

x-coordinate/longitude.

y property #

y-coordinate/latitude.

geotransform property #

Geotransform, derived from the coordinate arrays and then cached.

Deriving it reads the lon / lat MDArrays, which costs about 13 ms on a modest container. RasterBase.transform reads this property on every access, and xy / _get_indices read transform per call, so an uncached derivation made a loop of point lookups re-read the coordinate arrays once per point. The value is memoised and cleared wherever _geotransform itself is reassigned.

Returns:

Type Description

tuple[float, float, float, float, float, float]: The GDAL

geotransform.

epsg property writable #

EPSG code, resolving a container's code from its variables.

The base class caches _epsg during construction, but both of this class's CRS fallbacks resolve lazily — a container borrows its CRS from a variable, and a CF lat/lon grid infers WGS 84 from its axis units — and neither is available on the half-built object. So a cached None is not authoritative; re-derive it from :attr:crs, which runs those fallbacks, and only then report an absent CRS.

Returns:

Type Description
int | None

int | None: The EPSG code, or None when there is no CRS at all

int | None

(or the CRS carries no EPSG authority, e.g. geostationary).

variable_names property #

Names of data variables (excluding dimension coordinate arrays).

Order is the store's, not alphabetical. This previously reported the CF classification's own list, which is sorted, so every multi-variable container came back alphabetically whatever order the file declared -- a store declaring z then q reported ["q", "z"]. It now filters the declared list, so the answer is ["z", "q"]. The set of names is unchanged; only the order is, and nothing warns.

It is worth knowing about because the order is load-bearing: _fan_out_eager templates a container-wide result from the first spatial variable, taking its geotransform, CRS, no-data and extra dimensions, so a container-wide to_crs / resample now templates from a possibly different variable -- and the order propagates into to_netcdf and to_xarray. Call sorted(nc.variable_names) where the old order matters. See docs/migration.md, netcdf / unreleased.

Returns:

Type Description
list[str]

list[str]: Variable names, in the order the store declares them.

list[str]

For MDIM mode these come from GetMDArrayNames() minus dimension

list[str]

names; for classic mode from GetSubDatasets().

data_vars property #

xarray's name for :attr:variables — a mapping, as xarray's is.

Deliberately not an alias of :attr:variable_names, which is a list, for the reason :attr:dims gives at length: aliasing a mapping name onto a list is the variables-shaped collision this class is trying to avoid, and data_vars is the spelling a reader arriving from xarray reaches for first. ds.data_vars["t2m"] and ds.data_vars.items() both work in xarray, and both work here.

Iterating it still yields the names, so list(nc.data_vars) and len(nc.data_vars) read the same as they would against a list.

Read-through, not writable. nc.data_vars["x"] = ... raises TypeError: the mapping caches what it has loaded, so a write would be visible to nc["x"] and invisible to in, len and iteration. Use add_variable / set_variable / remove_variable.

Differs from xarray in what the values are: each is a NetCDF subset or a LabeledArray, where xarray's are DataArrays.

Returns:

Name Type Description
_LazyVariableDict _LazyVariableDict

Name to variable, loading each on first access.

Examples:

  • Iterating yields the names, as it does in xarray:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> list(nc.data_vars)
['area', 'msk_rgn', 'pr', 'tas', 'ua']
>>> len(nc.data_vars)
5
- Indexing reaches the variable, which is what the list form could not do:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> nc.data_vars["ua"].band_count
17
- So does items(), for a pass over names and variables together:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> [name for name, var in nc.data_vars.items() if var.band_count > 1]
['ua']
See Also

NetCDF.variables: The canonical member. NetCDF.variable_names: The names alone, as a list. NetCDF.dtypes: The same names, mapped to their types.

dims property #

Dimension name to length — a mapping, as xarray's Dataset.dims is.

Deliberately not an alias of :attr:dimension_names, which is a list. Aliasing a mapping name onto a list would be a second variables-shaped collision: a reader coming from xarray writes nc.dims["time"] and would silently get a list index. The list keeps its own name; this returns what the xarray spelling promises, which makes it an alias of :attr:dimension_sizes — the same mapping under xarray's name for it, built afresh on every call rather than handed out as one shared object.

Differs from xarray in two ways, and the second is a moving target:

  • It is a plain dict where xarray hands back a frozen mapping, so a write — nc.dims["lat"] = 1 — succeeds against that throwaway and is silently discarded rather than raising. Change an axis through the store, not through this.
  • xarray is deprecating Dataset.dims as a mapping. As of 2026.7.0 it returns a FrozenMappingWarningOnValuesAccess that warns when the values are read, on its way to returning a set of dimension names. When that lands, the member this one is named after will be closer to :attr:dimension_names than to this. :attr:sizes is the spelling that stays a name-to-length mapping on both sides, and is the one to reach for in code meant to last.

This member is not following it: dims as a mapping is what a reader arriving today expects, and matching a set of names would bring straight back the collision the name was chosen to avoid.

Inherits :attr:dimension_sizes' contract on a variable subset, where it is {}: a subset has no root group to enumerate dimensions from. :attr:dimension_names falls back to the names cached when the subset was built and so still reports them, which is the one place the two disagree about more than their shape.

A classic container (open_as_multi_dimensional=False) has no root group either, so it is {} there too however many variables the store holds — and there :attr:dimension_names has no cache to fall back on and answers None.

Returns:

Type Description
dict[str, int]

dict[str, int]: Dimension name to its length. {} on a variable subset and on a

dict[str, int]

classic container.

Examples:

  • Keys are the names, values the lengths:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.dims["time"], sorted(nc.dims) == sorted(nc.dimension_names)
(4, True)
- A variable subset reports none, while its names survive:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc["temperature"].dims
{}
>>> nc["temperature"].dimension_names[:2]
['time', 'pressure_level']
See Also

NetCDF.dimension_sizes: The canonical member, and where the subset rule comes from. NetCDF.dimension_names: The names alone, as a list.

sizes property #

xarray's other name for :attr:dimension_sizes. Read-only alias.

Differs from xarray in the same way :attr:dims does: a plain dict, not a Frozen, so a write is silently discarded rather than refused. It is also {} wherever dims is — on a variable subset and on a classic container, neither of which has a root group to enumerate dimensions from.

Returns:

Type Description
dict[str, int]

dict[str, int]: Dimension name to its length. {} on a variable subset and on a

dict[str, int]

classic container.

Examples:

  • Look up one axis's length:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> nc.sizes["plev"], nc.sizes["lat"]
(17, 128)
- Size the grid a variable is defined on:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.sizes["lat"] * nc.sizes["lon"]
30
See Also

NetCDF.dimension_sizes: The canonical member. NetCDF.dims: The same mapping, under xarray's other name for it.

attrs property #

xarray's name for :attr:global_attributes. Read-only alias.

On a classic container (open_as_multi_dimensional=False) there is no root group to read, and :attr:global_attributes falls back to GDAL's GetMetadata(). What comes back then is not the root group's attribute list: the global attributes are prefixed (NC_GLOBAL#Conventions), every variable's own attributes are folded in under their own prefix (temperature#units), and GDAL's synthetic NETCDF_DIM_* entries are added. Key by the prefixed spelling there, or open the store in MDIM mode.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The root group's attributes, or GDAL's whole metadata dictionary

dict[str, Any]

on a classic container.

Examples:

  • Read the root group's attributes:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.attrs
{'Conventions': 'CF-1.6'}
- Ask a busier store which convention it follows:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> nc.attrs["Conventions"]
'CF-1.0'
>>> len(nc.attrs) > 10
True
- The same store read classically answers under GDAL's prefixed keys instead:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file(
...     "tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc",
...     open_as_multi_dimensional=False,
... )
>>> nc.attrs["NC_GLOBAL#Conventions"], nc.attrs["temperature#units"]
('CF-1.6', 'K')
See Also

NetCDF.global_attributes: The canonical member, and the setters set_global_attribute / delete_global_attribute.

coords property #

Every dimension's stored coordinate, as {name: ndarray}.

Built from :meth:get_dimension_values, so the storage-order contract lives in one place: a spatial axis comes back as the file stores it, which for a south-to-north file is the reverse of the raster's north-up rows. A dimension with no indexing variable is omitted rather than mapped to None.

The keys come from :attr:dimension_names, not from :attr:dims — on a variable subset dims is {} while the names survive, so reading dims here would drop the axes a subset can still report. set(nc.coords) <= set(nc.dimension_names) is therefore the invariant, and it is the one to rely on. The subset inclusion is strict in practice, and by more than one axis: a subset tracks only its band dimensions, so every spatial axis answers None from :meth:get_dimension_values and drops out — the x axis as much as the y, and whether or not either was renamed. A subset of a (time, level, lat, lon) cube therefore reports time and level only. The renaming of the y axis to the window it was cut with (subset_lat_4_-1_5) is real but incidental; a store whose subset keeps both axis names loses both coordinates just the same. A spatial axis's cell centres come from the geotransform instead, via :meth:~pyramids.dataset.transform.GeoTransform.x_axis / :meth:~pyramids.dataset.transform.GeoTransform.y_axis.

A classic container (open_as_multi_dimensional=False) has no dimension names at all — :attr:dimension_names is None there, not a list, so the invariant above has nothing to hold over and this answers {} however many variables the store enumerates.

Values are read when this is called, one array per dimension; the arrays are small (one per axis, not per cell) but this is not free.

Differs from xarray, whose coords[name] is a DataArray carrying its own dims and attrs. These are bare arrays: nc.coords["lat"].values is an AttributeError, because the array is the values. They are also undecoded: a CF time axis stays the raw offsets its units count, not datetime64.

Returns:

Type Description
dict[str, NDArray]

dict[str, numpy.ndarray]: Dimension name to its stored coordinate. {} on a

dict[str, NDArray]

classic container.

Examples:

  • Read the axes the store actually indexes:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> sorted(nc.coords)
['lat', 'lon', 'pressure_level', 'time']
>>> nc.coords["lat"].tolist()
[40.0, 41.0, 42.0, 43.0, 44.0]
>>> nc.coords["time"].tolist()
[0.0, 6.0, 12.0, 18.0]
- Find which index a label sits at, which is what the mapping is for:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.coords["pressure_level"].tolist().index(850.0)
1
- A dimension with no indexing variable is absent, not None:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> "bnds" in nc.dims, "bnds" in nc.coords
(True, False)
- A subset keeps only its band axes. Here neither spatial axis was renamed, and both drop out all the same:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__20v__1d3-3d17__y-desc.nc")
>>> subset = nc["tcw"]
>>> subset.dimension_names
['time', 'latitude', 'longitude']
>>> sorted(subset.coords)
['time']
>>> subset.get_dimension_values("longitude") is None
True
See Also

NetCDF.get_dimension_values: The per-name accessor this reads. NetCDF.to_xarray: Which decodes a CF time axis, where this does not.

dtypes property #

Each data variable's dtype, as {name: dtype}.

One entry per distinct name in :attr:variable_names — a list that a classic container can repeat, as the "Differs from xarray" note below spells out — so a variable subset, which enumerates none, reports {}.

No data variable's array is read. For a raster variable the type comes from the band description. Opening one does read its coordinate axes — one small array per dimension, never one per cell — which is what "reads no pixels" means here.

A variable with no raster plane is the exception: it comes back as a LabeledArray, and building one materialises the variable's own array. So on a store of 1-D or non-numeric variables this reads each of them, and the arrays stay in the :attr:variables cache afterwards. The type reported is then the materialised array's, which for a string variable is 'object' rather than any type the store uses.

A name the store enumerates but will not open reports "unknown" rather than raising. That happens on a classic container, whose subdataset enumeration can report an array GDAL then declines to open; introspection describes what it can instead of stopping at the first such name.

Differs from xarray in two ways: the values are str, not numpy.dtype objects, so nc.dtypes["t2m"] == np.float64 is False where xarray's is True — compare np.dtype(nc.dtypes["t2m"]) instead. And being a mapping it is keyed by unique name, while :attr:variable_names is a list that a classic container can repeat: on cf__40v__1d28-2d9-3d3__nc4.nc opened classically that list has 12 entries and 9 distinct names, so len(nc.dtypes) is 9 while len(nc) is 12.

Returns:

Type Description
dict[str, str]

dict[str, str]: Variable name to the dtype of its first band, or to the LabeledArray's own dtype for a variable with no raster plane.

Warns:

Type Description
UserWarning

Once per name reported "unknown", naming it and the refusal behind it. Once per distinct name, not once per occurrence — the walk is over dict.fromkeys(variable_names), so a classic container that enumerates the same name twice still warns about it once.

Examples:

  • One entry per data variable:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> nc.dtypes["ua"], nc.dtypes["msk_rgn"]
('float32', 'int32')
- Pick out the variables that are not floating point:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> [name for name, kind in nc.dtypes.items() if "int" in kind]
['msk_rgn']
- Read classically, the same store types the names GDAL will not open unknown and carries on with the rest:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file(
...     "tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc",
...     open_as_multi_dimensional=False,
... )
>>> nc.dtypes["precipitation_flux"], nc.dtypes["area"]
('unknown', 'float32')
See Also

NetCDF.nbytes: Sized from these dtypes and the dimension lengths. NetCDF.variable_names: The names these are keyed by.

nbytes property #

The size of every data variable in bytes, computed rather than read.

From each variable's shape and dtype. Data variables only — xarray's nbytes counts its coordinates too, so the two differ by the size of the coordinate arrays.

Three things to know before trusting the number:

  • A raster variable is sized without reading a single cell, which is the point: a cube far larger than memory can still be sized. Its coordinate axes are read — one small array per dimension — because opening a variable resolves them.
  • A variable with no raster plane is a LabeledArray, and building one materialises its array — so those are read, not computed. A string variable is then counted as 8 bytes per Python object pointer, which bears no relation to the characters stored.
  • A packed variable is sized by its stored band type, not by what :meth:read_array returns. Reading an int16 store that unpacks to float64 costs four times this figure, not one.

See :attr:dtypes for the same caveats on the types.

It sizes what :attr:variable_names enumerates, which on a variable subset is nothing: nc["t2m"].nbytes is 0, not that variable's own size. Size a single variable from its shape and :attr:dtype instead.

Sized over distinct names, so this and :attr:dtypes agree on what "the data variables" are: a classic container's enumeration can report the same name twice, and summing the list would count that variable twice.

A name the store enumerates but will not open — see :attr:dtypes — contributes 0 and a UserWarning naming it. The shortfall can be large rather than marginal: a classic container's subdataset list can name a variable by its standard_name (precipitation_flux for pr), which GDAL then declines to open, so the three largest cubes of cf__12v__1d4-2d5-3d2-4d1__y-asc.nc contribute nothing and the total is 9.7% of the real size. Open the store multidimensionally to size it.

Returns:

Name Type Description
int int

The total, 0 for a container with no data variables.

Examples:

  • A 12-band 5x6 float64 cube is 2880 bytes:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.nbytes
2880
- Decide whether a store is worth reading, without reading it:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> round(nc.nbytes / 1024**2, 2)
2.62
See Also

NetCDF.dtypes: The types this sizes from. NetCDF.dims: The lengths it sizes over.

variables property #

All data variables as a lazy dict of {name: subset}.

Variables are loaded on first access per key, not all at once. Cached after loading; invalidated by add_variable / remove_variable / set_variable.

Each entry is whatever get_variable returns for that name: a NetCDF subset for a raster variable, a LabeledArray for one with no raster plane -- a 1-D array, or a non-numeric one of any rank. A store that holds both kinds yields both from one mapping, so check what you have before reaching for a raster method.

Returns:

Name Type Description
_LazyVariableDict _LazyVariableDict

Mapping from variable name to its subset. The concrete type

_LazyVariableDict

is internal and not exported — treat it as a read-through

_LazyVariableDict

Mapping[str, NetCDF | LabeledArray] whose keys, values and items return

_LazyVariableDict

lists rather than views, and which refuses writes.

Examples:

  • A store with a grid and a profile axis yields one of each kind:
    >>> import numpy as np
    >>> from osgeo import gdal
    >>> from pyramids.netcdf import Container
    >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
    >>> group = store.GetRootGroup()
    >>> lat = group.CreateDimension("lat", None, None, 2)
    >>> lon = group.CreateDimension("lon", None, None, 3)
    >>> ilev = group.CreateDimension("ilev", None, None, 4)
    >>> grid = group.CreateMDArray(
    ...     "t2m", [lat, lon], gdal.ExtendedDataType.Create(gdal.GDT_Float32)
    ... )
    >>> grid.Write(np.arange(6, dtype="float32").reshape(2, 3))
    0
    >>> profile = group.CreateMDArray(
    ...     "hyai", [ilev], gdal.ExtendedDataType.Create(gdal.GDT_Float64)
    ... )
    >>> profile.Write(np.array([0.0, 0.25, 0.5, 1.0]))
    0
    >>> variables = Container(store).variables
    >>> sorted(variables.keys())
    ['hyai', 't2m']
    >>> variables["t2m"].shape
    (1, 2, 3)
    >>> variables["hyai"].values.tolist()
    [0.0, 0.25, 0.5, 1.0]
    
See Also

get_variable: the accessor behind each entry, and the full account of when a name answers with a LabeledArray.

file_name property #

File path, with the NETCDF:"path":var prefix stripped if present.

Returns:

Name Type Description
str

Clean file path without the NETCDF prefix.

time_stamp property #

Time coordinate values parsed from the CF-compliant time variable.

Returns:

Type Description

list[str] | None: Formatted time strings, or None if no time dimension with a units attribute is found.

scale property writable #

Per-band factor a read applies -- the variable's own _scale when it has one.

Dataset.scale reports each GDAL band's slot. A NetCDF variable can also carry its recipe in Python (_scale / _offset), which a read applies ahead of the band's, and sel() builds exactly that: a band declaring nothing with the recipe held only on the variable. Reporting the band there said [1.0] while every read was unpacked with 0.01, so this answers from _effective_packing -- the same resolver the reads use -- and the property and the values cannot disagree.

Returns:

Type Description
list[float]

list[float]: One factor per band; 1.0 where a band is not packed.

Examples:

  • A selection holds its recipe only in Python, and still reports it:
    >>> import numpy as np
    >>> from pyramids.netcdf import NetCDF, GeoReference
    >>> counts = np.arange(2 * 3 * 4, dtype="int16").reshape(2, 3, 4)
    >>> nc = NetCDF.from_array(
    ...     counts,
    ...     geo_ref=GeoReference(top_left_corner=(0.0, 3.0), cell_size=1.0, epsg=4326),
    ...     variable_name="t",
    ... )
    >>> variable = nc.get_variable("t")
    >>> variable._scale, variable._offset = 0.01, 1.5
    >>> selected = variable.sel(**{variable._band_dim_name: variable._band_dim_values[1]})
    >>> selected.raster.GetRasterBand(1).GetScale() is None
    True
    >>> selected.scale, selected.offset
    ([0.01], [1.5])
    

offset property writable #

Per-band offset a read applies -- the variable's own _offset when it has one.

See :attr:scale for why this answers from _effective_packing rather than from the GDAL band's slot.

Returns:

Type Description
list[float]

list[float]: One offset per band; 0.0 where a band is not packed.

meta_data property writable #

Structured metadata for this NetCDF.

Uses the GDAL Multidimensional API (groups, arrays, dimensions) when the file was opened with open_as_multi_dimensional=True. Falls back to the classic NETCDF_DIM_* parser (dimensions.py) when opened in classic mode (no root group available).

Cached on first access. Invalidated by add_variable/remove_variable.

Returns:

Type Description
NetCDFMetadata

NetCDFMetadata

Raises:

Type Description
RuntimeError

The dataset has been closed (matching the base getter).

dimension_sizes property #

Logical size of every dimension, in storage order, as {name: size}.

Reads the true dimension lengths from the multidimensional root group (e.g. {"time": 128568, "y": 3840, "x": 4608}). Prefer this over :attr:shape on a chunked cloud store: shape reflects the classic single-raster view (band count + a chunk-sized window), so a remote Zarr whose CF time axis GDAL can't parse reports (0, chunk_y, chunk_x) rather than the logical grid. Empty for a variable subset (no root group).

Returns:

Type Description
dict[str, int]

dict[str, int]: Mapping of dimension name to its length; {} when

dict[str, int]

the cube is a variable subset rather than a root MDIM container.

Examples:

  • True grid sizes of a NWM retrospective cube (needs the bucket)::

    nc.dimension_sizes # doctest: +SKIP

dimension_names property #

Names of all dimensions in storage order.

On the root MDIM container the names come from the GDAL root group (e.g. ["x", "y", "time"]). On a variable subset returned by get_variable() the names come from the cached _md_array_dims captured at subset-build time, so 4-D+ cubes report all dims (e.g. ["valid_time", "pressure_level", "latitude", "longitude"]) without touching private state.

Returns:

Type Description
list[str] | None

list[str] or None: Dim names. None only on a cube that

list[str] | None

has neither a root group nor cached _md_array_dims.

group_names property #

Names of sub-groups in the root group.

Returns:

Type Description
list[str]

list[str]: Sub-group names (e.g. ["forecast", "analysis"]).

list[str]

Empty list if no sub-groups exist or the dataset is in

list[str]

classic mode.

is_subset property #

Whether this object represents a single-variable subset.

Returns:

Name Type Description
bool bool

True if the dataset is a variable subset extracted via get_variable().

is_md_array property #

Whether this dataset was opened in multidimensional mode.

Returns:

Name Type Description
bool

True if the dataset was opened via gdal.OF_MULTIDIM_RASTER and supports groups, MDArrays, and dimensions.

global_attributes property #

Global attributes from the root group.

Returns a live dict read from the GDAL root group each time. For MDIM mode, reads from the root group's attributes. For classic mode, reads from GDAL's GetMetadata().

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Key-value mapping of global attributes.

__reduce__() #

Emit the extended recipe tuple carrying NetCDF mode flags.

Overrides :meth:RasterBase.__reduce__ to include _is_md_array, _is_subset, _source_var_name, _group_path, and _open_options (the seven-element recipe), which are required to reconstruct a container, group view, or variable-subset with matching identity and driver options.

For variable-subset instances the _file_name attribute reflects the subset's GDAL description, which is typically empty or driver-specific. We therefore fall back to the parent container's _file_name when reconstructing a subset.

Security

The recipe carries _open_options verbatim, so any secret a driver accepts as an open option is serialised into the pickle (spilled to disk / quoted in dask error reports). Treat such a pickle as a secret.

Raises:

Type Description
TypeError

The NetCDF is in-memory, not on-disk — an empty _file_name, a /vsimem/ path, or a _vsimem_path backing store shadowed by a cosmetic name= (checked on self or, for a subset, the parent). Pickling an in-memory NetCDF is not supported.

Source code in src/pyramids/netcdf/netcdf.py
def __reduce__(self):  # type: ignore[override]
    """Emit the extended recipe tuple carrying NetCDF mode flags.

    Overrides :meth:`RasterBase.__reduce__` to include
    `_is_md_array`, `_is_subset`, `_source_var_name`, `_group_path`,
    and `_open_options` (the seven-element recipe), which are
    required to reconstruct a container, group view, or
    variable-subset with matching identity and driver options.

    For variable-subset instances the `_file_name` attribute
    reflects the subset's GDAL description, which is typically
    empty or driver-specific. We therefore fall back to the
    parent container's `_file_name` when reconstructing a
    subset.

    Security:
        The recipe carries `_open_options` verbatim, so any secret a
        driver accepts as an open option is serialised into the pickle
        (spilled to disk / quoted in dask error reports). Treat such a
        pickle as a secret.

    Raises:
        TypeError: The NetCDF is in-memory, not on-disk — an empty
            `_file_name`, a `/vsimem/` path, or a `_vsimem_path`
            backing store shadowed by a cosmetic `name=` (checked on
            `self` or, for a subset, the parent). Pickling an
            in-memory NetCDF is not supported.
    """
    path = self._file_name
    parent = getattr(self, "_parent_nc", None)
    if (not path) and (self._is_subset or self._group_path) and parent is not None:
        path = parent._file_name
    # A cosmetic `from_bytes(name=...)` shadows `_file_name`; the real backing store is recorded
    # in `_vsimem_path` (on self, or the parent container for a subset). Consult it so a *named*
    # in-memory NetCDF raises the same immediate TypeError as an unnamed one, instead of pickling
    # into a recipe that only fails on unpickle (#1059; shadow family #1050/#1053/#1057/#1058).
    vsimem = getattr(self, "_vsimem_path", None) or (
        getattr(parent, "_vsimem_path", None) if parent is not None else None
    )
    if not path or path.startswith("/vsimem/") or vsimem:
        raise TypeError(
            f"NetCDF has no on-disk path (file_name={self._file_name!r}); "
            "pickling an in-memory NetCDF is not supported. Call "
            ".to_file(path) first to anchor it to disk."
        )
    return (
        _reconstruct_netcdf,
        (
            path,
            self._access,
            bool(self._is_md_array),
            bool(self._is_subset),
            self._source_var_name,
            self._group_path,
            tuple(self._open_options),
        ),
    )

__init__(src, access='read_only', open_as_multi_dimensional=True, *, open_options=None) #

Initialize a NetCDF dataset wrapper.

Parameters:

Name Type Description Default
src Dataset

A GDAL dataset handle (either classic or multidimensional).

required
access str

Access mode, either "read_only" or "write". Defaults to "read_only".

'read_only'
open_as_multi_dimensional bool

If True the dataset was opened with gdal.OF_MULTIDIM_RASTER and supports groups, MDArrays, and dimensions. If False it was opened in classic raster mode (subdatasets, bands). Defaults to True.

True
open_options tuple[str, ...] | list[str] | None

GDAL open options this store was opened with, captured so the base reopen paths (per-thread handles, lazy chunks= reads) reapply them. None (default) captures nothing (#1025).

None
Source code in src/pyramids/netcdf/netcdf.py
def __init__(
    self,
    src: gdal.Dataset,
    access: str = "read_only",
    open_as_multi_dimensional: bool = True,
    *,
    open_options: tuple[str, ...] | list[str] | None = None,
):
    """Initialize a NetCDF dataset wrapper.

    Args:
        src: A GDAL dataset handle (either classic or multidimensional).
        access: Access mode, either `"read_only"` or `"write"`.
            Defaults to `"read_only"`.
        open_as_multi_dimensional: If True the dataset was opened with
            `gdal.OF_MULTIDIM_RASTER` and supports groups, MDArrays,
            and dimensions. If False it was opened in classic raster
            mode (subdatasets, bands). Defaults to True.
        open_options: GDAL open options this store was opened with,
            captured so the base reopen paths (per-thread handles,
            lazy `chunks=` reads) reapply them. `None` (default)
            captures nothing (#1025).
    """
    if type(self) is NetCDF:
        # API-1 (#614): NetCDF is now the base of Container / Variable.
        # Direct construction is deprecated; the typed entry points return the right
        # concrete class. Subclass construction (type(self) is a subclass) is silent.
        warnings.warn(
            "Directly constructing NetCDF is deprecated and will stop returning a "
            "usable instance in a future major release. Open a store with "
            "NetCDF.read_file(...) / NetCDF.from_array(...) (returns a "
            "Container) and extract variables with container.get_variable(...) "
            "(returns a Variable). NetCDF remains an isinstance-compatible base "
            "for one major version.",
            DeprecationWarning,
            stacklevel=2,
        )
    super().__init__(src, access=access, open_options=open_options)
    # set the is_subset to false before retrieving the variables
    if open_as_multi_dimensional:
        self._is_md_array = True
        self._is_subset = False
    else:
        self._is_md_array = False
        self._is_subset = False
    # Caches (invalidated by _replace_raster, add_variable, remove_variable)
    self._cached_variables: _LazyVariableDict | None = None
    self._cached_meta_data: NetCDFMetadata | None = None
    # Memoised `geotransform`; see that property. Cleared wherever
    # `_geotransform` is reassigned, so the two cannot disagree.
    self._derived_geotransform: tuple | None = None
    # The grid a rebuild was told to produce, when its coordinates cannot describe it — a
    # spatial axis one cell long carries no spacing to derive one from. `get_variable` hands
    # it to the variables it builds, which have no coordinate arrays of their own, the
    # `geotransform` property answers it ahead of deriving one, and `_restore_stamped_grid`
    # replays it over whatever `_update_inplace` or `_replace_raster` re-derived.
    self._stamped_geotransform: tuple | None = None
    # Origin-tracking attributes set by get_variable (RT-4)
    self._parent_nc: NetCDF | None = None
    self._source_var_name: str | None = None
    # True on a raster rebuilt cell by cell in memory (`where`, `fillna`, the null
    # flags): it keeps `_source_var_name` so it is still called what it is called,
    # but its values are its own and no longer live at `file::source_var_name`. A
    # lazy read, which reopens that path, has to refuse it rather than reach for a
    # store that does not hold these cells.
    self._rebuilt_in_memory: bool = False
    # ARC-12: a group view shares the parent container's open dataset and
    # records the "/"-joined path to its working sub-group here. None (the
    # default) means this container is rooted at the dataset's root group;
    # `_working_group()` resolves the active group from this field so a
    # `get_group()` view reads variables/dims/attrs without copying data.
    self._group_path: str | None = None
    self._gdal_md_arr_ref: Any = None
    self._gdal_rg_ref: Any = None
    # Whether get_variable reversed a south-to-north Y axis for this cube (None until a variable
    # subset is read). The eager materialize path replays it on the fast classic driver; declared
    # here so it is a class invariant snapshotted alongside the _gdal_* refs in _update_inplace.
    self._md_y_flipped: bool | None = None
    # Whether get_variable reversed an east-to-west X axis for this cube. Legal CF, written by
    # nobody in practice, but a raster mirrored west-east is silently wrong if it slips through.
    self._md_x_flipped: bool | None = None
    # (x_index, y_index) of the raster plane within the MDArray's dimensions, resolved by
    # _read_md_array. The eager materialize path needs them to rebuild the unreversed view.
    self._md_spatial_dims: tuple[int, int] | None = None
    # True once the AsClassicDataset view has been replaced by a window-readable MEM raster
    # (see _materialize_md_view). Tracks the raster, so _update_inplace carries it over.
    self._md_view_materialized: bool = False
    # The raster `get_variable` built from the store, while this variable still holds it (see
    # `_reads_as_its_store`). Dropped by `close()` and by a swap to another raster through
    # `_update_inplace` or `_replace_raster`, so it neither keeps the source file open nor keeps
    # a replaced copy of the values alive; `_materialize_md_view` moves it to its MEM copy.
    self._store_raster: gdal.Dataset | None = None
    # True once a geostationary scan-angle geotransform has been rescaled to
    # metres on this cube; tells the `geotransform` property to trust the
    # stored geotransform instead of re-deriving radian spacing from x/y.
    self._geostationary_scaled: bool = False
    # Per-variable cache of the classic-driver geostationary geotransform, populated
    # on the parent container so each variable's metre geotransform is resolved with
    # at most one `NETCDF:<file>:<var>` open instead of re-opening on every access.
    self._geostationary_gt_cache: dict[str, tuple[float, ...] | None] = {}
    # Memoised container CRS borrowed from the first georeferenced variable
    # (see `_container_crs`); None until resolved, "" when no variable has one.
    self._container_crs_cache: str | None = None
    # Memoised fully-resolved CRS (container borrow + CF inference +
    # recorded globals). Same lifetime as the cache above.
    self._crs_cache: str | None = None
    # Whether `epsg` has run its lazy resolution; distinguishes a resolved
    # `None` (no CRS) from "not looked yet" (see the `epsg` property).
    self._epsg_resolved: bool = False
    self._md_array_dims: list[str] = []
    self._band_dim_name: str | None = None
    self._band_dim_values: list[Any] | None = None
    self._band_dim_names: tuple[str, ...] = ()
    self._band_dim_values_map: dict[str, list[Any] | None] = {}
    # `(units, calendar)` of the band dimensions whose units are CF time units (not a level's
    # `millibar`), carried by a result computed in memory, and by a variable `get_variable`
    # built, so their stamps still decode without a store to read them from (see
    # `_time_attr_candidates`).
    self._band_dim_time_attrs: dict[str, tuple[str, str]] = {}
    self._band_dim_sizes: tuple[int, ...] = ()
    self._variable_attrs: dict[str, Any] = {}
    self._scale: float | None = None
    self._offset: float | None = None
    # NetCDF-specific engine collaborators (issue #615, STR-1). Distinct
    # attribute names from the eight inherited Dataset engines so they do
    # not clobber `self.io` / `self.spatial` / … . NetCDF exposes thin
    # façade methods that delegate here (e.g. `nc.<method>()` ->
    # `self.interop.<method>()`).
    self.interop = Interop(self)
    # `varops` (not `variables`) because `variables` is an existing read-side
    # property returning the lazy variable dict — the engine must not shadow it.
    self.varops = Variables(self)
    self.selection = Selection(self)

close() #

Release every GDAL handle this container holds, then close the base.

A NetCDF container keeps more open GDAL references than the single self._raster that :meth:Dataset.close drops:

  • cached per-variable child objects (:attr:_cached_variables), each with its own _raster and SWIG MDArray / root-group references;
  • the _gdal_md_arr_ref / _gdal_rg_ref views that keep an extracted variable's C++ backing alive;
  • the _parent_nc back-reference forming a refcount cycle with the parent's variable cache;
  • a variable's _store_raster, the record of the raster get_variable built, which (unless copied into memory) reads the store and so holds a file-backed store's file open.

This override closes the cached children and drops every extra reference before deferring to :meth:Dataset.close. It then runs a single :func:gc.collect: a spatial op (crop / to_crs / reduce) extracts variables whose AsClassicDataset view, MDArray and root group form a reference cycle among the GDAL SWIG wrappers that plain refcounting cannot reclaim. Without the collect those wrappers keep the source file open, so on Windows a later os.replace / os.remove fails with PermissionError until the caller forces a GC themselves (#564). Doing it here makes close() honour its contract — the file is unlocked immediately. Safe to call more than once.

Source code in src/pyramids/netcdf/netcdf.py
def close(self) -> None:
    """Release every GDAL handle this container holds, then close the base.

    A NetCDF container keeps more open GDAL references than the single
    `self._raster` that :meth:`Dataset.close` drops:

    * cached per-variable child objects (:attr:`_cached_variables`), each
      with its own `_raster` and SWIG MDArray / root-group references;
    * the `_gdal_md_arr_ref` / `_gdal_rg_ref` views that keep an
      extracted variable's C++ backing alive;
    * the `_parent_nc` back-reference forming a refcount cycle with the
      parent's variable cache;
    * a variable's `_store_raster`, the record of the raster `get_variable` built, which
      (unless copied into memory) reads the store and so holds a file-backed store's file
      open.

    This override closes the cached children and drops every extra reference
    before deferring to :meth:`Dataset.close`. It then runs a single
    :func:`gc.collect`: a spatial op (`crop` / `to_crs` / `reduce`)
    extracts variables whose `AsClassicDataset` view, MDArray and root
    group form a **reference cycle among the GDAL SWIG wrappers** that plain
    refcounting cannot reclaim. Without the collect those wrappers keep the
    source file open, so on Windows a later `os.replace` / `os.remove`
    fails with `PermissionError` until the caller forces a GC themselves
    (#564). Doing it here makes `close()` honour its contract — the file is
    unlocked immediately. Safe to call more than once.
    """
    # Release handles parked by this container's lazy reads (#727): a lazy dask array can be held
    # alive across close() (the finalizer only fires on drop), so close() explicitly evicts them
    # here so a subsequent reopen of the same file does not leave two live handles. The lazy array
    # stays usable -- its CachingFileManager re-opens on the next chunk read.
    self._release_lazy_managers()
    cached = self._cached_variables
    if cached is not None:
        # Only the materialised children (bypass the lazy dict's loading
        # ``values()`` so closing does not force every variable open).
        for child in dict.values(cached):
            if isinstance(child, Dataset):
                child.close()
        self._cached_variables = None
    self._gdal_md_arr_ref = None
    self._gdal_rg_ref = None
    # The ad-hoc view/warp keep-alive pins are set only on some code paths (a
    # GetView Y-flip or a to_crs warp), so they are not initialised in __init__;
    # clear them here too — otherwise they hold their backing GDAL handle past
    # close(), defeating the handle-release contract the gc.collect() below enforces.
    self._view_source = None
    self._warp_source = None
    self._parent_nc = None
    # Unless copied into memory, the raster `get_variable` recorded reads the store (the
    # `AsClassicDataset` view of a multidimensional open, or the file itself opened classic), so
    # keeping it would keep a file-backed store's file open.
    self._store_raster = None
    self._cached_meta_data = None
    # Drop and close the reopened geolocation-source handle (a base Dataset over
    # NETCDF:"<file>":<var>, memoised by `_geolocation_source`). Left open it keeps the source
    # file open past close(), defeating the handle-release contract the gc.collect() below
    # enforces (#564); `_update_inplace` already pops it. Only a genuine reopen is cached
    # (never `self`), so closing it is safe.
    geoloc_memo = self.__dict__.pop("_geolocation_source_memo", None)
    if isinstance(geoloc_memo, Dataset):
        geoloc_memo.close()
    super().close()
    # Break the GDAL SWIG view/MDArray/root-group cycle left by variable
    # extraction so the source file is released now, not on the next GC.
    gc.collect()

__str__() #

Return a human-readable summary, or a <Dataset: closed> sentinel when closed.

The summary is chosen by type, through :meth:_summary_text, which :class:Container and :class:Variable override. Defining one summary here and letting both inherit it made the container describe raster fields it does not have: rows / columns / cell_size fell through to GDAL's in-memory placeholder, so a 12x5x5 cube at 0.25 degrees reported itself as 512 x 512 at cell size 1.0 (#1090).

Type rather than band_count: a container opened in classic mode carries the store's bands directly, so band_count >= 1 there and a band-count test would label it a variable. The classes already encode the distinction read_file and get_variable established, so they are the discriminator.

Mirrors Dataset.__str__: a closed handle returns the sentinel rather than raising, so the repr/str stays total for debuggers and logging (__repr__ already inherits this via super()).

This changes str() / print() only. __repr__ is a separate contract -- gdal.Info on the underlying raster -- and is deliberately unchanged, so the surfaces that auto-display an object still report the placeholder: a bare nc in a notebook cell, a debugger's variable pane and pytest's assertion output all call repr, and a container still shows Size is 512, 512 there. Reworking that is a separate question about Dataset.__repr__ and its gdal.Info contract.

Returns:

Name Type Description
str

The type-specific summary; <Dataset: closed> when the handle is closed, or <Container: summary unavailable> / <Variable: summary unavailable> when building the summary raised.

Source code in src/pyramids/netcdf/netcdf.py
def __str__(self):
    """Return a human-readable summary, or a `<Dataset: closed>` sentinel when closed.

    The summary is chosen by **type**, through :meth:`_summary_text`, which
    :class:`Container` and :class:`Variable` override. Defining one summary here and
    letting both inherit it made the container describe raster fields it does not have:
    `rows` / `columns` / `cell_size` fell through to GDAL's in-memory placeholder,
    so a 12x5x5 cube at 0.25 degrees reported itself as 512 x 512 at cell size 1.0
    (#1090).

    Type rather than `band_count`: a container opened in classic mode carries the
    store's bands directly, so `band_count >= 1` there and a band-count test would
    label it a variable. The classes already encode the distinction `read_file` and
    `get_variable` established, so they are the discriminator.

    Mirrors `Dataset.__str__`: a closed handle returns the sentinel rather than
    raising, so the repr/str stays total for debuggers and logging (`__repr__`
    already inherits this via `super()`).

    This changes `str()` / `print()` only. `__repr__` is a separate contract --
    `gdal.Info` on the underlying raster -- and is deliberately unchanged, so the
    surfaces that auto-display an object still report the placeholder: a bare `nc` in a
    notebook cell, a debugger's variable pane and pytest's assertion output all call
    `repr`, and a container still shows `Size is 512, 512` there. Reworking that is a
    separate question about `Dataset.__repr__` and its `gdal.Info` contract.

    Returns:
        str: The type-specific summary; `<Dataset: closed>` when the handle is closed,
            or `<Container: summary unavailable>` / `<Variable: summary unavailable>`
            when building the summary raised.
    """
    message = "<Dataset: closed>"
    if self._raster is not None:
        try:
            message = self._summary_text()
        except Exception as error:
            # Total by contract, not just for a closed handle. `str()` runs in debuggers,
            # logging and pytest introspection, where a raising summary masks the error the
            # caller was actually looking at. The summary walks a dozen properties, any of
            # which can fail on a half-built or exotic store, so degrade to a sentinel that
            # still says what this is -- and log what went wrong, so the failure is
            # diagnosable rather than merely survivable.
            logger.debug(
                "%s summary failed: %r", type(self).__name__, error, exc_info=True
            )
            message = f"<{type(self).__name__}: summary unavailable>"
    return message

__repr__() #

repr.

Source code in src/pyramids/netcdf/netcdf.py
def __repr__(self):
    """__repr__."""
    return super().__repr__()

info(buf=None) #

Print a summary of the container: dimensions, variables and attributes.

Calls :attr:dtypes, so it inherits that member's caveat: a variable with no raster plane is materialised to report its type.

On a grouped store the dimensions: section lists only the root group's, which is what :attr:dimension_sizes reports, while the variable lines reference each group's own axis of the same name — none__35v__1d35__groups-nc4.nc declares one recNum and then names recNum on 29 variables whose lengths differ. Faithful to the members it prints, but not something ncdump -h would emit.

A classic container (open_as_multi_dimensional=False) has no multidim group behind it, and three parts of the report thin out as a result:

  • Every variable is printed with an empty axis list, because the per-variable dimension names are read from that group.
  • The dimensions: section is empty — :attr:dimension_sizes is enumerated from the same group — so variables: follows it immediately, however many variables the store holds.
  • A name the classic subdataset list reports but GDAL declines to open is typed unknown, from :attr:dtypes, rather than taking the whole summary down.

The attribute block grows rather than thins: :attr:attrs falls back to GDAL's GetMetadata() there, so a global attribute is spelled :NC_GLOBAL#Conventions = … and every variable's own attributes (:temperature#units = …) and GDAL's synthetic NETCDF_DIM_* entries are listed alongside them.

Parameters:

Name Type Description Default
buf TextIO | None

An open text stream to write to. Defaults to sys.stdout, matching xarray's info.

None

Warns:

Type Description
UserWarning

Once per name typed unknown, raised through :attr:dtypes. A summary that reports three unknown lines has warned three times, so a caller filtering warnings rather than reading the report still learns the store's enumeration is unreliable.

Examples:

  • The whole summary of a small store. The real output indents with tabs, as ncdump -h does; they are expanded here so the example reads:

>>> import io
>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> report = io.StringIO()
>>> nc.info(report)
>>> print(report.getvalue().expandtabs(4), end="")
pyramids.NetCDF {
dimensions:
    time = 4 ;
    pressure_level = 3 ;
    lat = 5 ;
    lon = 6 ;
variables:
    float64 temperature(time, pressure_level, lat, lon) ;
<BLANKLINE>
// global attributes:
    :Conventions = 'CF-1.6' ;
}
- Capture it instead of printing, to search or log it:

>>> import io
>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> report = io.StringIO()
>>> nc.info(report)
>>> [line.strip() for line in report.getvalue().splitlines() if "ua(" in line]
['float32 ua(time, plev, lat, lon) ;']
- The same store read classically: no dimension lines, and no axes on a variable:

>>> import io
>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file(
...     "tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc",
...     open_as_multi_dimensional=False,
... )
>>> report = io.StringIO()
>>> nc.info(report)
>>> lines = report.getvalue().splitlines()
>>> [line.expandtabs(4) for line in lines[:4]]
['pyramids.NetCDF {', 'dimensions:', 'variables:', '    float32 area() ;']
>>> [line.strip() for line in lines if "precipitation_flux" in line]
['unknown precipitation_flux() ;']
See Also

NetCDF.dtypes: The per-variable types this prints. NetCDF.dims: The dimension lengths it prints.

Source code in src/pyramids/netcdf/netcdf.py
def info(self, buf: TextIO | None = None) -> None:
    """Print a summary of the container: dimensions, variables and attributes.

    Calls :attr:`dtypes`, so it inherits that member's caveat: a variable with no
    raster plane is materialised to report its type.

    On a **grouped** store the `dimensions:` section lists only the root group's, which
    is what :attr:`dimension_sizes` reports, while the variable lines reference each
    group's own axis of the same name — `none__35v__1d35__groups-nc4.nc` declares one
    `recNum` and then names `recNum` on 29 variables whose lengths differ. Faithful to
    the members it prints, but not something `ncdump -h` would emit.

    A **classic** container (`open_as_multi_dimensional=False`) has no multidim group
    behind it, and three parts of the report thin out as a result:

    - Every variable is printed with an **empty axis list**, because the per-variable
      dimension names are read from that group.
    - The `dimensions:` section is **empty** — :attr:`dimension_sizes` is enumerated from
      the same group — so `variables:` follows it immediately, however many variables the
      store holds.
    - A name the classic subdataset list reports but GDAL declines to open is typed
      `unknown`, from :attr:`dtypes`, rather than taking the whole summary down.

    The attribute block grows rather than thins: :attr:`attrs` falls back to GDAL's
    `GetMetadata()` there, so a global attribute is spelled `:NC_GLOBAL#Conventions = …`
    and every variable's own attributes (`:temperature#units = …`) and GDAL's synthetic
    `NETCDF_DIM_*` entries are listed alongside them.

    Args:
        buf: An open text stream to write to. Defaults to `sys.stdout`, matching
            xarray's `info`.

    Warns:
        UserWarning: Once per name typed `unknown`, raised through :attr:`dtypes`. A
            summary that reports three `unknown` lines has warned three times, so a
            caller filtering warnings rather than reading the report still learns the
            store's enumeration is unreliable.

    Examples:
        - The whole summary of a small store. The real output indents with tabs, as
          `ncdump -h` does; they are expanded here so the example reads:

          ```python
          >>> import io
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> report = io.StringIO()
          >>> nc.info(report)
          >>> print(report.getvalue().expandtabs(4), end="")
          pyramids.NetCDF {
          dimensions:
              time = 4 ;
              pressure_level = 3 ;
              lat = 5 ;
              lon = 6 ;
          variables:
              float64 temperature(time, pressure_level, lat, lon) ;
          <BLANKLINE>
          // global attributes:
              :Conventions = 'CF-1.6' ;
          }

          ```
        - Capture it instead of printing, to search or log it:

          ```python
          >>> import io
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> report = io.StringIO()
          >>> nc.info(report)
          >>> [line.strip() for line in report.getvalue().splitlines() if "ua(" in line]
          ['float32 ua(time, plev, lat, lon) ;']

          ```
        - The same store read classically: no dimension lines, and no axes on a variable:

          ```python
          >>> import io
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file(
          ...     "tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc",
          ...     open_as_multi_dimensional=False,
          ... )
          >>> report = io.StringIO()
          >>> nc.info(report)
          >>> lines = report.getvalue().splitlines()
          >>> [line.expandtabs(4) for line in lines[:4]]
          ['pyramids.NetCDF {', 'dimensions:', 'variables:', '    float32 area() ;']
          >>> [line.strip() for line in lines if "precipitation_flux" in line]
          ['unknown precipitation_flux() ;']

          ```

    See Also:
        NetCDF.dtypes: The per-variable types this prints.
        NetCDF.dims: The dimension lengths it prints.
    """
    stream = sys.stdout if buf is None else buf
    # `None` on a classic container, which has no multidim group to read axes from;
    # `_variable_dim_names` would reach `None.OpenMDArray` and raise `AttributeError`.
    rg = self._working_group()
    types = self.dtypes
    # The names come from `types`, which `dtypes` has just built from its own walk.
    # Reading `variable_names` again would re-query `GetMDArrayNames()` and re-run CF
    # classification — the expensive half of the summary on a large store — and any
    # disagreement between the two walks would surface as a bare `KeyError` from the
    # `types[name]` below, out of a read-only method. `dtypes` is already keyed by
    # distinct name, so this is distinct too.
    names = list(types)
    lines = ["pyramids.NetCDF {", "dimensions:"]
    lines += [f"\t{name} = {size} ;" for name, size in self.dimension_sizes.items()]
    lines.append("variables:")
    for name in names:
        # The variable's own axes, read from the store rather than from the subset
        # `get_variable` hands back: a subset renames its y dimension to the window it was
        # cut with (`subset_lat_4_-1_5`), which is not a name this file has.
        axes = "" if rg is None else ", ".join(self._variable_dim_names(rg, name))
        lines.append(f"\t{types[name]} {name}({axes}) ;")
    lines.append("")
    lines.append("// global attributes:")
    # An attribute value is arbitrary text: a ROMS store carries a `CPP_options` over
    # 1,000 characters and an `NLM_LBC` full of newlines, either of which destroys the
    # `ncdump -h` shape this is imitating. Collapsed to one line and truncated.
    lines += [
        f"\t:{key} = {_summarised(value)} ;" for key, value in self.attrs.items()
    ]
    lines.append("}")
    print("\n".join(lines), file=stream)

get(name, default=None) #

The variable called name, or default when the container has no such variable.

Parameters:

Name Type Description Default
name str

A data-variable name.

required
default Any

What to return when name is absent. Defaults to None.

None

Returns:

Type Description
Any

NetCDF | LabeledArray | Any: The variable — a NetCDF subset or a

Any

LabeledArray — or default when there is no such name. Annotated Any

Any

because default is unconstrained; a checker cannot narrow it further.

Raises:

Type Description
RuntimeError

name is one of the data variables but GDAL refuses to open it. Only a classic container reaches this — its subdataset enumeration can report an array that will not open — and default does not cover it, so a name being present is not on its own a guarantee of a value. :attr:dtypes and :attr:nbytes do swallow the refusal; this does not.

Examples:

  • A miss returns the default rather than raising:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.get("nope", "absent")
'absent'
- A hit returns the variable, so it can be used straight away:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.get("temperature").band_count
12
- With no default, a miss is None — enough to branch on:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.get("nope") is None
True
See Also

NetCDF.getitem: The same lookup, raising KeyError on a miss.

Source code in src/pyramids/netcdf/netcdf.py
def get(self, name: str, default: Any = None) -> Any:
    """The variable called `name`, or `default` when the container has no such variable.

    Args:
        name: A data-variable name.
        default: What to return when `name` is absent. Defaults to `None`.

    Returns:
        NetCDF | LabeledArray | Any: The variable — a `NetCDF` subset or a
        `LabeledArray` — or `default` when there is no such name. Annotated `Any`
        because `default` is unconstrained; a checker cannot narrow it further.

    Raises:
        RuntimeError: `name` **is** one of the data variables but GDAL refuses to open
            it. Only a **classic** container reaches this — its subdataset enumeration
            can report an array that will not open — and `default` does not cover it, so
            a name being present is not on its own a guarantee of a value. :attr:`dtypes`
            and :attr:`nbytes` do swallow the refusal; this does not.

    Examples:
        - A miss returns the default rather than raising:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc.get("nope", "absent")
          'absent'

          ```
        - A hit returns the variable, so it can be used straight away:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc.get("temperature").band_count
          12

          ```
        - With no default, a miss is `None` — enough to branch on:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc.get("nope") is None
          True

          ```

    See Also:
        NetCDF.__getitem__: The same lookup, raising `KeyError` on a miss.
    """
    return self.variables.get(name, default)

__getitem__(name) #

The variable called name — nc["t2m"], the first thing an xarray user tries.

Delegates to :attr:variables, so it is the same lazy, cached object :meth:get_variable returns and the refusal message is the one that mapping already gives.

Parameters:

Name Type Description Default
name str

A data-variable name, as :attr:variable_names lists it.

required

Returns:

Type Description
NetCDF | LabeledArray

NetCDF | LabeledArray: A NetCDF subset for a raster variable, a

NetCDF | LabeledArray

LabeledArray for one with no raster plane.

Raises:

Type Description
KeyError

name is not a data variable. get_variable raises ValueError for the same miss; the mapping protocol has to raise KeyError for in, get and dict(nc) to behave, so the two spellings differ deliberately here.

RuntimeError

name is enumerated but GDAL refuses to open it. Reachable on a classic container, whose subdataset list can report an array that is not there. :attr:dtypes and :attr:nbytes swallow that refusal so introspection survives it; indexing lets it through.

Examples:

  • Reach a variable by name:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc["temperature"].band_count
12
- A name the store does not hold at all is a bare KeyError:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc["nope"]
Traceback (most recent call last):
    ...
KeyError: 'nope'
- A name the store does hold, but not as a data variable, is told apart: the message names the accessor that will read it, and that accessor does:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> try:
...     nc["lat_bnds"]
... except KeyError as miss:
...     print(miss.args[0].split(";")[0])
'lat_bnds' is not a data variable, so it is not a key of `variables`
>>> nc.get_variable("lat_bnds").shape
(1, 128, 2)

Read-only: nc["t2m"] = other raises TypeError, because a store is written through named methods rather than by item assignment.

See Also

NetCDF.get_variable: The same lookup, raising ValueError instead. NetCDF.variables: The mapping this reads. NetCDF.add_variable: Writes a new variable; set_variable replaces one and remove_variable drops one.

Source code in src/pyramids/netcdf/netcdf.py
def __getitem__(self, name: str) -> NetCDF | LabeledArray:
    """The variable called `name` — `nc["t2m"]`, the first thing an xarray user tries.

    Delegates to :attr:`variables`, so it is the same lazy, cached object
    :meth:`get_variable` returns and the refusal message is the one that mapping
    already gives.

    Args:
        name: A data-variable name, as :attr:`variable_names` lists it.

    Returns:
        NetCDF | LabeledArray: A `NetCDF` subset for a raster variable, a
        `LabeledArray` for one with no raster plane.

    Raises:
        KeyError: `name` is not a data variable. `get_variable` raises `ValueError` for
            the same miss; the mapping protocol has to raise `KeyError` for `in`, `get`
            and `dict(nc)` to behave, so the two spellings differ deliberately here.
        RuntimeError: `name` is enumerated but GDAL refuses to open it. Reachable on a
            **classic** container, whose subdataset list can report an array that is not
            there. :attr:`dtypes` and :attr:`nbytes` swallow that refusal so introspection
            survives it; indexing lets it through.

    Examples:
        - Reach a variable by name:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc["temperature"].band_count
          12

          ```
        - A name the store does not hold at all is a bare `KeyError`:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc["nope"]
          Traceback (most recent call last):
              ...
          KeyError: 'nope'

          ```
        - A name the store *does* hold, but not as a data variable, is told apart: the
          message names the accessor that will read it, and that accessor does:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> try:
          ...     nc["lat_bnds"]
          ... except KeyError as miss:
          ...     print(miss.args[0].split(";")[0])
          'lat_bnds' is not a data variable, so it is not a key of `variables`
          >>> nc.get_variable("lat_bnds").shape
          (1, 128, 2)

          ```

    Read-only: `nc["t2m"] = other` raises `TypeError`, because a store is written
    through named methods rather than by item assignment.

    See Also:
        NetCDF.get_variable: The same lookup, raising `ValueError` instead.
        NetCDF.variables: The mapping this reads.
        NetCDF.add_variable: Writes a new variable; `set_variable` replaces one and
            `remove_variable` drops one.
    """
    return self.variables[name]

__array__(dtype=None, copy=None) #

Refuse to become an array. Read one with :meth:read_array instead.

:meth:__iter__ and :meth:__len__ make every NetCDF look like a sequence to NumPy, which would otherwise convert one by looping it. That answer is wrong in two different ways, and the second is dangerous:

  • a container yields its variable names, so np.asarray(nc) would build an array of strings — visibly not data;
  • a variable yields nothing at all, because a variable holds no data variables of its own. NumPy would see an empty sequence, and np.mean(nc["ua"]) would answer nan for a cube of 557,056 real values rather than raise.

The second is why this refuses rather than documents. nan is what a reader gets from averaging nothing, it looks like a legitimate result, and it can travel a long way before anyone questions it. Before the mapping protocol existed NumPy could not interpret a NetCDF at all and raised, which was the right answer; this restores it.

The same judgement :meth:Dataset.__bool__ already makes for if nc: — there is no honest single answer, so decline to invent one.

dtype=object is the one request that is honoured: boxing a dataset in an object array reads nothing and invents nothing — the object is stored as itself — and it worked before the mapping protocol existed, so np.array([nc], dtype=object) still gives (1,) rather than starting to raise.

Parameters:

Name Type Description Default
dtype Any

Requested dtype. object boxes the dataset; anything else is refused.

None
copy Any

NumPy 2's copy-semantics flag. Accepted so the call signature matches; unused, since nothing is ever copied.

None

Returns:

Type Description
ndarray

numpy.ndarray: A 0-d object array holding this dataset, for dtype=object.

Raises:

Type Description
TypeError

For any other dtype, including the default.

Examples:

  • The refusal names the way to get the data:

>>> import numpy as np
>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> np.asarray(nc["temperature"])
Traceback (most recent call last):
    ...
TypeError: a NetCDF cannot be converted to an array directly...
- Reading the variable works, and is what the message points at:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc["temperature"].read_array().shape
(4, 3, 5, 6)
- Iterating and indexing are untouched — only the value coercion is refused:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> list(nc), len(nc)
(['temperature'], 1)
- Boxing in an object array still works, and holds the dataset itself:

>>> import numpy as np
>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> boxed = np.array([nc], dtype=object)
>>> boxed.shape
(1,)
>>> boxed[0] is nc
True
See Also

NetCDF.read_array: Reads a variable's values as an array. NetCDF.coords: The coordinate axes, which are ordinary arrays. NetCDF.iter: What iterating a container yields, and why it is not data.

Source code in src/pyramids/netcdf/netcdf.py
def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray:
    """Refuse to become an array. Read one with :meth:`read_array` instead.

    :meth:`__iter__` and :meth:`__len__` make every `NetCDF` look like a sequence to
    NumPy, which would otherwise convert one by looping it. That answer is wrong in two
    different ways, and the second is dangerous:

    - a **container** yields its variable *names*, so `np.asarray(nc)` would build an
      array of strings — visibly not data;
    - a **variable** yields nothing at all, because a variable holds no data variables
      of its own. NumPy would see an empty sequence, and `np.mean(nc["ua"])` would
      answer `nan` for a cube of 557,056 real values rather than raise.

    The second is why this refuses rather than documents. `nan` is what a reader gets
    from averaging nothing, it looks like a legitimate result, and it can travel a long
    way before anyone questions it. Before the mapping protocol existed NumPy could not
    interpret a `NetCDF` at all and raised, which was the right answer; this restores it.

    The same judgement :meth:`Dataset.__bool__` already makes for `if nc:` — there is no
    honest single answer, so decline to invent one.

    `dtype=object` is the one request that is honoured: boxing a dataset in an object
    array reads nothing and invents nothing — the object is stored as itself — and it
    worked before the mapping protocol existed, so `np.array([nc], dtype=object)` still
    gives `(1,)` rather than starting to raise.

    Args:
        dtype: Requested dtype. `object` boxes the dataset; anything else is refused.
        copy: NumPy 2's copy-semantics flag. Accepted so the call signature matches;
            unused, since nothing is ever copied.

    Returns:
        numpy.ndarray: A 0-d object array holding this dataset, for `dtype=object`.

    Raises:
        TypeError: For any other dtype, including the default.

    Examples:
        - The refusal names the way to get the data:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> np.asarray(nc["temperature"])
          Traceback (most recent call last):
              ...
          TypeError: a NetCDF cannot be converted to an array directly...

          ```
        - Reading the variable works, and is what the message points at:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc["temperature"].read_array().shape
          (4, 3, 5, 6)

          ```
        - Iterating and indexing are untouched — only the value coercion is refused:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> list(nc), len(nc)
          (['temperature'], 1)

          ```
        - Boxing in an object array still works, and holds the dataset itself:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> boxed = np.array([nc], dtype=object)
          >>> boxed.shape
          (1,)
          >>> boxed[0] is nc
          True

          ```

    See Also:
        NetCDF.read_array: Reads a variable's values as an array.
        NetCDF.coords: The coordinate axes, which are ordinary arrays.
        NetCDF.__iter__: What iterating a container yields, and why it is not data.
    """
    if dtype is not None and np.dtype(dtype) == object:
        # Boxing a dataset in an object array is not a coercion — nothing is read and
        # nothing is invented, the object is stored as itself. It worked before the
        # mapping protocol existed and there is no reason for this to take it away.
        box = np.empty((), dtype=object)
        box[()] = self
        return box
    raise TypeError(
        "a NetCDF cannot be converted to an array directly — a container holds many "
        "variables and a variable holds none of its own, so iterating either gives "
        "names or nothing rather than values. Read the data with "
        'nc["name"].read_array(), or take an axis from nc.coords["lat"].'
    )

__contains__(name) #

Whether name is one of this container's data variables.

Parameters:

Name Type Description Default
name object

The name to look for. A non-string is simply absent.

required

Returns:

Name Type Description
bool bool

True when :attr:variable_names lists it.

Examples:

  • Membership follows variable_names, not the store's whole array list — lat is a dimension coordinate, so it has values but is not a member, and those values come from :attr:coords rather than from an item lookup:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> "temperature" in nc, "lat" in nc, "nope" in nc
(True, False, False)
>>> nc.coords["lat"].tolist()
[40.0, 41.0, 42.0, 43.0, 44.0]
- Guard a lookup with it, which is what the protocol is for:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> [name for name in ("tas", "rsds", "pr") if name in nc]
['tas', 'pr']
See Also

NetCDF.variable_names: What membership is decided against. NetCDF.coords: Where a dimension coordinate such as lat is read from — get_variable will not take it, and raises ValueError if asked. NetCDF.get_variable: Reads the other kind of non-member — a bounds or curvilinear-coordinate array such as lat_bnds, which the store declares and this enumeration still leaves out.

Source code in src/pyramids/netcdf/netcdf.py
def __contains__(self, name: object) -> bool:
    """Whether `name` is one of this container's data variables.

    Args:
        name: The name to look for. A non-string is simply absent.

    Returns:
        bool: `True` when :attr:`variable_names` lists it.

    Examples:
        - Membership follows `variable_names`, not the store's whole array list — `lat`
          is a dimension coordinate, so it has values but is not a member, and those
          values come from :attr:`coords` rather than from an item lookup:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> "temperature" in nc, "lat" in nc, "nope" in nc
          (True, False, False)
          >>> nc.coords["lat"].tolist()
          [40.0, 41.0, 42.0, 43.0, 44.0]

          ```
        - Guard a lookup with it, which is what the protocol is for:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> [name for name in ("tas", "rsds", "pr") if name in nc]
          ['tas', 'pr']

          ```

    See Also:
        NetCDF.variable_names: What membership is decided against.
        NetCDF.coords: Where a dimension coordinate such as `lat` is read from —
            `get_variable` will **not** take it, and raises `ValueError` if asked.
        NetCDF.get_variable: Reads the *other* kind of non-member — a bounds or
            curvilinear-coordinate array such as `lat_bnds`, which the store declares
            and this enumeration still leaves out.
    """
    return name in self.variables

__iter__() #

Iterate the data-variable names, in store order.

Data variables only, matching :attr:variables and :attr:variable_names — a dimension coordinate such as lat is not yielded, so list(nc) and nc.variable_names agree. That choice is the one place this class could have collided with xarray twice over: xarray.Dataset.__iter__ yields data variables too, but xarray.Dataset.variables includes coordinates while this class's does not. (Spelled out because Dataset is also the name of this class's own base, which has neither member.)

This made a container look like a sequence to everything that duck-types. Before these dunders existed, a NetCDF had neither __iter__ nor __len__, so NumPy treated it as an opaque object and isinstance(nc, Iterable) was False. Both changed:

expression before now
isinstance(nc, Iterable / Sized / Container) False True
np.asarray(nc) a 0-d object box raises — see :meth:__array__
np.asarray(nc["t2m"]) raises raises

Left alone, NumPy would have converted a container by looping it — yielding an array of the names — and a variable into an empty array, since a variable yields nothing. :meth:__array__ refuses both. A container's coercion therefore changes from a useless value to an error, and a variable's stays an error, which is the one that mattered: np.mean over that empty array answered nan. isinstance(nc, Mapping) and isinstance(nc, Sequence) are both still False.

The change that remains is the isinstance row, and it has one practical consequence: a helper that accepts "anything iterable" used to reject a container outright and now accepts it, receiving a list of name strings. Gate on isinstance(x, NetCDF) first where that matters.

bool(nc) is unaffected — :meth:Dataset.__bool__ still refuses to collapse a raster to one truth value, and wins over __len__; use len(nc) == 0 to ask whether a container is empty.

Yields:

Name Type Description
str str

Each data-variable name.

Examples:

  • Iterating yields the names, in store order:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> list(nc)
['area', 'msk_rgn', 'pr', 'tas', 'ua']
- A dimension coordinate is not among them, which is the whole choice:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> list(nc)
['temperature']
>>> sorted(nc.dims)
['lat', 'lon', 'pressure_level', 'time']
See Also

NetCDF.variable_names: The list this yields from. NetCDF.dims: The dimensions, which iteration deliberately skips.

Source code in src/pyramids/netcdf/netcdf.py
def __iter__(self) -> Iterator[str]:
    """Iterate the **data-variable names**, in store order.

    Data variables only, matching :attr:`variables` and :attr:`variable_names` — a
    dimension coordinate such as `lat` is not yielded, so `list(nc)` and
    `nc.variable_names` agree. That choice is the one place this class could have
    collided with xarray twice over: `xarray.Dataset.__iter__` yields data variables
    too, but `xarray.Dataset.variables` includes coordinates while this class's does
    not. (Spelled out because `Dataset` is also the name of this class's own base,
    which has neither member.)

    **This made a container look like a sequence to everything that duck-types.**
    Before these dunders existed, a `NetCDF` had neither `__iter__` nor `__len__`, so
    NumPy treated it as an opaque object and `isinstance(nc, Iterable)` was `False`.
    Both changed:

    | expression | before | now |
    |---|---|---|
    | `isinstance(nc, Iterable / Sized / Container)` | `False` | `True` |
    | `np.asarray(nc)` | a 0-d object box | raises — see :meth:`__array__` |
    | `np.asarray(nc["t2m"])` | raises | raises |

    Left alone, NumPy would have converted a container by looping it — yielding an array
    of the *names* — and a variable into an empty array, since a variable yields nothing.
    :meth:`__array__` refuses both. A container's coercion therefore changes from a
    useless value to an error, and a variable's stays an error, which is the one that
    mattered: `np.mean` over that empty array answered `nan`. `isinstance(nc, Mapping)`
    and `isinstance(nc, Sequence)` are both still `False`.

    The change that remains is the `isinstance` row, and it has one practical
    consequence: a helper that accepts "anything iterable" used to reject a container
    outright and now accepts it, receiving a list of name strings. Gate on
    `isinstance(x, NetCDF)` first where that matters.

    `bool(nc)` is unaffected — :meth:`Dataset.__bool__` still refuses to collapse a
    raster to one truth value, and wins over `__len__`; use `len(nc) == 0` to ask
    whether a container is empty.

    Yields:
        str: Each data-variable name.

    Examples:
        - Iterating yields the names, in store order:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> list(nc)
          ['area', 'msk_rgn', 'pr', 'tas', 'ua']

          ```
        - A dimension coordinate is not among them, which is the whole choice:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> list(nc)
          ['temperature']
          >>> sorted(nc.dims)
          ['lat', 'lon', 'pressure_level', 'time']

          ```

    See Also:
        NetCDF.variable_names: The list this yields from.
        NetCDF.dims: The dimensions, which iteration deliberately skips.
    """
    return iter(self.variables)

__len__() #

How many data variables the container holds.

bool(nc) does not follow from this: :meth:Dataset.__bool__ takes precedence over __len__ and raises, because a raster's truth value is ambiguous. Ask len(nc) == 0 for "does this container hold any variables". See :meth:__iter__ for the rest of what adding these dunders changed about protocol dispatch.

Returns:

Name Type Description
int int

len(variable_names). Reads no data.

Examples:

  • Count what the store offers:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> len(nc)
5
- A store with one variable counts one, though it declares four dimensions:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> len(nc), len(nc.dims)
(1, 4)
See Also

NetCDF.variable_names: What is counted.

Source code in src/pyramids/netcdf/netcdf.py
def __len__(self) -> int:
    """How many data variables the container holds.

    `bool(nc)` does **not** follow from this: :meth:`Dataset.__bool__` takes precedence
    over `__len__` and raises, because a raster's truth value is ambiguous. Ask
    `len(nc) == 0` for "does this container hold any variables". See :meth:`__iter__`
    for the rest of what adding these dunders changed about protocol dispatch.

    Returns:
        int: `len(variable_names)`. Reads no data.

    Examples:
        - Count what the store offers:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> len(nc)
          5

          ```
        - A store with one variable counts one, though it declares four dimensions:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> len(nc), len(nc.dims)
          (1, 4)

          ```

    See Also:
        NetCDF.variable_names: What is counted.
    """
    return len(self.variables)

keys() #

The data-variable names, in store order. Reads nothing.

Differs from xarray, whose keys() is a live KeysView that tracks the dataset. This is a snapshot: a copy, taken when you call it, which is why it is safe to sort or reverse in place.

Returns:

Type Description
list[str]

list[str]: The same names as :attr:variable_names, as a fresh list.

Examples:

  • A list, not a view, matching the mapping it delegates to:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> nc.keys()
['temperature']
- A fresh copy each call, so reordering it in place disturbs nothing:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> names = nc.keys()
>>> names.reverse()
>>> names
['ua', 'tas', 'pr', 'msk_rgn', 'area']
>>> list(nc)
['area', 'msk_rgn', 'pr', 'tas', 'ua']
See Also

NetCDF.variable_names: The canonical list. NetCDF.items: The same names, paired with their variables.

Source code in src/pyramids/netcdf/netcdf.py
def keys(self) -> list[str]:
    """The data-variable names, in store order. Reads nothing.

    **Differs from xarray**, whose `keys()` is a live `KeysView` that tracks the
    dataset. This is a snapshot: a copy, taken when you call it, which is why it is safe
    to sort or reverse in place.

    Returns:
        list[str]: The same names as :attr:`variable_names`, as a fresh list.

    Examples:
        - A list, not a view, matching the mapping it delegates to:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> nc.keys()
          ['temperature']

          ```
        - A fresh copy each call, so reordering it in place disturbs nothing:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> names = nc.keys()
          >>> names.reverse()
          >>> names
          ['ua', 'tas', 'pr', 'msk_rgn', 'area']
          >>> list(nc)
          ['area', 'msk_rgn', 'pr', 'tas', 'ua']

          ```

    See Also:
        NetCDF.variable_names: The canonical list.
        NetCDF.items: The same names, paired with their variables.
    """
    return self.variables.keys()

values() #

Every variable, loading each one.

Loading is eager and all-or-nothing: the list is built by indexing every name, so one variable that will not open takes the whole call down. Iterate :attr:variable_names and index yourself when you need to survive that.

Returns:

Type Description
list[NetCDF | LabeledArray]

list[NetCDF | LabeledArray]: A NetCDF per raster variable, a LabeledArray

list[NetCDF | LabeledArray]

for the rest, in store order.

Raises:

Type Description
RuntimeError

GDAL refuses one of the enumerated variables. Reachable on a classic container, whose subdataset list can name an array that will not open — see :meth:__getitem__, which raises it one variable at a time.

Examples:

  • Each entry is the variable itself, so its properties are to hand:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> [variable.band_count for variable in nc.values()]
[1, 1, 1, 1, 17]
- Reduce over them, which is what having the objects is for:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> max(variable.rows for variable in nc.values())
5
See Also

NetCDF.items: The same variables, paired with their names. NetCDF.keys: The names alone, which reads nothing.

Source code in src/pyramids/netcdf/netcdf.py
def values(self) -> list[NetCDF | LabeledArray]:
    """Every variable, loading each one.

    Loading is eager and all-or-nothing: the list is built by indexing every name, so
    one variable that will not open takes the whole call down. Iterate
    :attr:`variable_names` and index yourself when you need to survive that.

    Returns:
        list[NetCDF | LabeledArray]: A `NetCDF` per raster variable, a `LabeledArray`
        for the rest, in store order.

    Raises:
        RuntimeError: GDAL refuses one of the enumerated variables. Reachable on a
            **classic** container, whose subdataset list can name an array that will not
            open — see :meth:`__getitem__`, which raises it one variable at a time.

    Examples:
        - Each entry is the variable itself, so its properties are to hand:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> [variable.band_count for variable in nc.values()]
          [1, 1, 1, 1, 17]

          ```
        - Reduce over them, which is what having the objects is for:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> max(variable.rows for variable in nc.values())
          5

          ```

    See Also:
        NetCDF.items: The same variables, paired with their names.
        NetCDF.keys: The names alone, which reads nothing.
    """
    return self.variables.values()

items() #

(name, variable) for every variable, loading each.

Eager in the same all-or-nothing way :meth:values is: every name is indexed before any pair is returned.

Returns:

Type Description
list[tuple[str, NetCDF | LabeledArray]]

list[tuple[str, NetCDF | LabeledArray]]: Pairs in store order.

Raises:

Type Description
RuntimeError

GDAL refuses one of the enumerated variables — the values failure mode, since this loads the same objects.

Examples:

  • Unpackable in a loop, as a mapping's items are:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> [(name, variable.band_count) for name, variable in nc.items()]
[('area', 1), ('msk_rgn', 1), ('pr', 1), ('tas', 1), ('ua', 17)]
- Filter on a property of the variable rather than on its name:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> [name for name, variable in nc.items() if variable.band_count > 1]
['ua']
See Also

NetCDF.keys: The names alone, which reads nothing. NetCDF.values: The variables alone.

Source code in src/pyramids/netcdf/netcdf.py
def items(self) -> list[tuple[str, NetCDF | LabeledArray]]:
    """`(name, variable)` for every variable, loading each.

    Eager in the same all-or-nothing way :meth:`values` is: every name is indexed before
    any pair is returned.

    Returns:
        list[tuple[str, NetCDF | LabeledArray]]: Pairs in store order.

    Raises:
        RuntimeError: GDAL refuses one of the enumerated variables — the `values`
            failure mode, since this loads the same objects.

    Examples:
        - Unpackable in a loop, as a mapping's items are:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> [(name, variable.band_count) for name, variable in nc.items()]
          [('area', 1), ('msk_rgn', 1), ('pr', 1), ('tas', 1), ('ua', 17)]

          ```
        - Filter on a property of the variable rather than on its name:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> [name for name, variable in nc.items() if variable.band_count > 1]
          ['ua']

          ```

    See Also:
        NetCDF.keys: The names alone, which reads nothing.
        NetCDF.values: The variables alone.
    """
    return self.variables.items()

stats(*args, **kwargs) #

Container-guarded facade for Dataset.stats.

Source code in src/pyramids/netcdf/netcdf.py
def stats(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.stats`."""
    self._check_not_container("stats")
    return super().stats(*args, **kwargs)

summary(variables=None, *, metrics=DEFAULT_METRICS, skipna=True, ddof=0) #

A per-variable summary table over the whole cube.

Where stats reports one row per band of a single variable, summary reports one row per variable, each reduced over all of its values — every band / time step and every cell. It therefore works on a container, which stats refuses. Values are read in physical units (CF scale_factor / add_offset applied) with no-data left out, exactly as read_array returns them.

Built on the shared :func:pyramids.base._summary.variable_summary, so this answers in the identical shape to :meth:pyramids.netcdf.ugrid.UgridDataset.summary.

Parameters:

Name Type Description Default
variables Sequence[str] | None

Which variables to summarise, in output-row order. None (the default) takes every variable in :attr:data_vars. An unknown name raises KeyError.

None
metrics Sequence[str]

Which statistics to report, in column order. Defaults to ("count", "min", "max", "mean", "std"); median / var / sum / prod are available too.

DEFAULT_METRICS
skipna bool

Leave no-data out of the statistics (the default) or let it propagate.

True
ddof int

Delta degrees of freedom for std / var. Defaults to 0.

0

Returns:

Type Description
DataFrame

pandas.DataFrame: One row per variable (index name "variable"), one column per metric.

DataFrame

count is int64; the rest are float64. A variable with no valid cell yields NaN

DataFrame

statistics and a count of 0.

Raises:

Type Description
KeyError

A requested variable is not in :attr:data_vars.

TypeError

A requested variable is non-numeric.

ValueError

An unknown metric.

Examples:

  • Summarise every variable of a multi-variable file:
>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
>>> df = nc.summary()
>>> list(df.index)
['area', 'msk_rgn', 'pr', 'tas', 'ua']
>>> list(df.columns)
['count', 'min', 'max', 'mean', 'std']
Source code in src/pyramids/netcdf/netcdf.py
def summary(
    self,
    variables: Sequence[str] | None = None,
    *,
    metrics: Sequence[str] = DEFAULT_METRICS,
    skipna: bool = True,
    ddof: int = 0,
) -> pd.DataFrame:
    """A per-variable summary table over the whole cube.

    Where `stats` reports one row per band of a single variable, `summary` reports one row
    per *variable*, each reduced over **all** of its values — every band / time step and every
    cell. It therefore works on a container, which `stats` refuses. Values are read in physical
    units (CF `scale_factor` / `add_offset` applied) with no-data left out, exactly as
    `read_array` returns them.

    Built on the shared :func:`pyramids.base._summary.variable_summary`, so this answers in the
    identical shape to :meth:`pyramids.netcdf.ugrid.UgridDataset.summary`.

    Args:
        variables: Which variables to summarise, in output-row order. `None` (the default)
            takes every variable in :attr:`data_vars`. An unknown name raises `KeyError`.
        metrics: Which statistics to report, in column order. Defaults to
            `("count", "min", "max", "mean", "std")`; `median` / `var` / `sum` / `prod` are
            available too.
        skipna: Leave no-data out of the statistics (the default) or let it propagate.
        ddof: Delta degrees of freedom for `std` / `var`. Defaults to 0.

    Returns:
        pandas.DataFrame: One row per variable (index name `"variable"`), one column per metric.
        `count` is `int64`; the rest are `float64`. A variable with no valid cell yields NaN
        statistics and a `count` of 0.

    Raises:
        KeyError: A requested variable is not in :attr:`data_vars`.
        TypeError: A requested variable is non-numeric.
        ValueError: An unknown metric.

    Examples:
        - Summarise every variable of a multi-variable file:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__12v__1d4-2d5-3d2-4d1__y-asc.nc")
          >>> df = nc.summary()
          >>> list(df.index)
          ['area', 'msk_rgn', 'pr', 'tas', 'ua']
          >>> list(df.columns)
          ['count', 'min', 'max', 'mean', 'std']

          ```
    """
    mapping = self.data_vars
    names = list(mapping) if variables is None else list(variables)
    arrays: dict[str, np.ndarray] = {}
    for name in names:
        if name not in mapping:
            raise KeyError(name)
        physical = _summary_physical(mapping[name])
        if physical is None:
            if variables is None:
                warnings.warn(
                    f"skipping non-numeric variable {name!r} in summary",
                    stacklevel=2,
                )
                continue
            raise TypeError(f"variable {name!r} is non-numeric; cannot summarise")
        arrays[name] = physical
    return variable_summary(arrays, metrics=metrics, skipna=skipna, ddof=ddof)

slope(*args, **kwargs) #

Container-guarded facade for Dataset.slope.

Source code in src/pyramids/netcdf/netcdf.py
def slope(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.slope`."""
    self._check_not_container("slope")
    return super().slope(*args, **kwargs)

hillshade(*args, **kwargs) #

Container-guarded facade for Dataset.hillshade.

Source code in src/pyramids/netcdf/netcdf.py
def hillshade(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.hillshade`."""
    self._check_not_container("hillshade")
    return super().hillshade(*args, **kwargs)

write_array(*args, **kwargs) #

Container-guarded facade for Dataset.write_array.

Source code in src/pyramids/netcdf/netcdf.py
def write_array(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.write_array`."""
    self._check_not_container("write_array")
    return super().write_array(*args, **kwargs)

to_cog(*args, **kwargs) #

Container-guarded facade for Dataset.to_cog.

Source code in src/pyramids/netcdf/netcdf.py
def to_cog(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.to_cog`."""
    self._check_not_container("to_cog")
    return super().to_cog(*args, **kwargs)

to_geodataframe(*args, **kwargs) #

Container-guarded facade for Dataset.to_geodataframe.

Source code in src/pyramids/netcdf/netcdf.py
def to_geodataframe(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.to_geodataframe`."""
    self._check_not_container("to_geodataframe")
    return super().to_geodataframe(*args, **kwargs)

zonal_stats(*args, **kwargs) #

Container-guarded facade for Dataset.zonal_stats.

Source code in src/pyramids/netcdf/netcdf.py
def zonal_stats(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.zonal_stats`."""
    self._check_not_container("zonal_stats")
    return super().zonal_stats(*args, **kwargs)

sample(*args, **kwargs) #

Container-guarded facade for Dataset.sample.

Source code in src/pyramids/netcdf/netcdf.py
def sample(self, *args, **kwargs):  # type: ignore[override]
    """Container-guarded facade for `Dataset.sample`."""
    self._check_not_container("sample")
    return super().sample(*args, **kwargs)

animate(variable=None, dim=None, **kwargs) #

Animate a variable along one of its non-spatial dimensions (issue #1380).

The cube could already animate, but only through plot(animate=...), where the capability is easy to miss. This is the same path under the name the other classes use (:meth:Dataset.animate, :meth:UgridDataset.animate, :meth:DatasetCollection.animate), so one name finds it everywhere.

The signature deliberately replaces the inherited raster one: a cube's frames come from one of its own dimensions, not from band indices, which is why :meth:plot overrides in the same way.

Parameters:

Name Type Description Default
variable str | None

Variable to animate. None (default) takes the only one.

None
dim str | None

Band dimension to animate along. None (default) takes the variable's primary band dimension.

None
**kwargs Any

Everything :meth:plot accepts on its animate path — selectors, colorbar, color, points, basemap, interval, …

{}

Returns:

Type Description
Any

cleopatra.glyphs.gridded.array_glyph.ArrayGlyph: The glyph, whose anim

Any

attribute is the :class:matplotlib.animation.FuncAnimation — save with

Any

glyph.anim.save("out.gif"). Note this differs from

Any

meth:UgridDataset.animate, which returns the FuncAnimation itself: the two

Any

cleopatra glyph families return different objects and pyramids passes each through

Any

rather than wrapping one to look like the other. Keep a reference to it —

Any

matplotlib warns and stops an animation held only as a temporary.

Raises:

Type Description
ValueError

No variable was given on a container, or variable is not one of its variables.

KeyError

dim is not a band dimension of that variable.

Note

The animate path is :meth:plot's, so anything else that method rejects is rejected here the same way and with the same message.

See Also

plot: One frame, or the same animation via animate=. UgridDataset.animate: The unstructured counterpart.

Source code in src/pyramids/netcdf/netcdf.py
def animate(  # type: ignore[override]
    self,
    variable: str | None = None,
    dim: str | None = None,
    **kwargs: Any,
) -> Any:
    """Animate a variable along one of its non-spatial dimensions (issue #1380).

    The cube could already animate, but only through ``plot(animate=...)``, where the
    capability is easy to miss. This is the same path under the name the other classes use
    (:meth:`Dataset.animate`, :meth:`UgridDataset.animate`,
    :meth:`DatasetCollection.animate`), so one name finds it everywhere.

    The signature deliberately replaces the inherited raster one: a cube's frames come
    from one of its own dimensions, not from band indices, which is why :meth:`plot`
    overrides in the same way.

    Args:
        variable: Variable to animate. ``None`` (default) takes the only one.
        dim: Band dimension to animate along. ``None`` (default) takes the variable's
            primary band dimension.
        **kwargs: Everything :meth:`plot` accepts on its animate path — ``selectors``,
            ``colorbar``, ``color``, ``points``, ``basemap``, ``interval``, …

    Returns:
        cleopatra.glyphs.gridded.array_glyph.ArrayGlyph: The glyph, whose ``anim``
        attribute is the :class:`matplotlib.animation.FuncAnimation` — save with
        ``glyph.anim.save("out.gif")``. Note this differs from
        :meth:`UgridDataset.animate`, which returns the ``FuncAnimation`` itself: the two
        cleopatra glyph families return different objects and pyramids passes each through
        rather than wrapping one to look like the other. **Keep a reference to it** —
        matplotlib warns and stops an animation held only as a temporary.

    Raises:
        ValueError: No variable was given on a container, or ``variable`` is not one of
            its variables.
        KeyError: ``dim`` is not a band dimension of that variable.

    Note:
        The animate path is :meth:`plot`'s, so anything else that method rejects is
        rejected here the same way and with the same message.

    See Also:
        plot: One frame, or the same animation via ``animate=``.
        UgridDataset.animate: The unstructured counterpart.
    """
    return self.plot(
        variable=variable, animate=dim if dim is not None else True, **kwargs
    )

plot(variable=None, *, selectors=None, facet=None, axes=None, kind='auto', animate=None, chunks=None, basemap=None, exclude_value=None, title=None, colorbar=None, points=None, color=None, contour=None, cells=None, data_style=None, fig=None, ax=None, **kwargs) #

Plot a 2-D slice of a NetCDF variable using the plot vocabulary.

The public surface is shaped around variables and dimensions — band is not a NetCDF concept and has been removed from the signature. Variable selection is by name; the slice to render is pinned via a :class:Selectors option bag (time / level / member / sel / isel / method); multi-panel layout is described by a :class:FacetSpec bag (col / row / col_wrap); and the spatial-axis interpretation by a :class:CoordinateSpec bag (coords / x_dim / y_dim). Each is a frozen dataclass — construct it inline at the call site. Colour and the render options mirror :meth:Dataset.plot: pass cmap / vmin / vmax / robust / extend as loose keywords and the grouped color / colorbar / contour / cells / data_style bags (all cleopatra's), so no NetCDF-specific colour bag is needed. colorbar=False drops the colour bar.

On a root MDIM container the variable= argument is required:

from pyramids.netcdf import NetCDF, Selectors
nc.plot(variable="t2m", selectors=Selectors(time="2024-01-15"))

On a variable subset (the result of :meth:get_variable) variable= may be omitted or must equal the pinned variable name; otherwise the call is rejected, mirroring the :meth:read_array contract.

Parameters:

Name Type Description Default
variable str

Name of the variable to plot. Required on the root MDIM container; must be None or equal to the pinned variable name on a subset. Defaults to None.

None
selectors Selectors

Dim-selector bag. See :class:Selectors for the field list. A missing bag is treated as :class:Selectors\ () (all fields None). Defaults to None.

None
facet FacetSpec

Faceting bag. See :class:FacetSpec for the field list. A missing bag (or one where both col and row are None) routes the call to the single-panel static-plot path. Defaults to None.

None
axes CoordinateSpec

Spatial-axis interpretation bag (:class:CoordinateSpec): an explicit curvilinear (x, y) coord pair (coords=), or the x_dim / y_dim dimension names when they cannot be auto-resolved. coords accepts a length-2 sequence of variable-name strings (looked up on the parent container) or of numpy arrays (1-D matching the data x/y axis, or 2-D matching (rows, cols)); when omitted, curvilinear coords are auto-detected from the CF coordinates attribute, then the well-known conventions (WRF XLAT/XLONG, ROMS lat_rho/lon_rho, NEMO nav_lat/nav_lon), else the renderer falls back to extent=self.bbox (imshow). x_dim / y_dim set the lon/lat dimensions for variables whose axes aren't trailing and carry no CF metadata (e.g. CAM T(time, lat, lev, lon)). Defaults to None.

None
kind str

Render kind forwarded to cleopatra's ArrayGlyph.plot. One of "auto", "imshow", "pcolormesh", "contour", "contourf". "auto" routes to pcolormesh when curvilinear coords are present, else imshow. Defaults to "auto".

'auto'
animate bool or str

When set, render the variable as an animation across a band dim instead of a single 2-D slice. True animates along the variable's primary band dim (typically time) — only valid when exactly one band dim remains after the selectors collapse the others. A string names the dim to animate along. None (default) returns a static plot. Mutually exclusive with faceting and with any selector that pins the animated dim. Defaults to None. On this animate path a frame_label=pyramids.plot.FrameLabel(...) (cleopatra

= 0.28) kwarg styles the per-frame caption — colour, size and placement — and is forwarded to cleopatra's animate call.

None
chunks Any

Chunking spec forwarded to :meth:read_array for the static-plot path. None (default) preserves the eager read. Any of int / tuple / dict / "auto" switches to the dask-backed lazy read and only the rendered slice is materialised. Has no effect on the animate= path. Defaults to None.

None
basemap bool, str, or Basemap

Reference layer, dispatched by type. True or a named basemap tile provider overlays a pyramids web-tile basemap; a pyramids.plot.Basemap draws a relief/features layer instead (not supported on the faceted path). Defaults to None.

None
exclude_value Any

Pixel value to mask out before plotting. Defaults to None.

None
title str

Plot title. Defaults to None.

None
colorbar bool or ColorBar

Colour-bar spec. A pyramids.plot.ColorBar(label=…, …) draws a configured bar; False hides it and None uses cleopatra's default. Only forwarded when set. Default is None.

None
points ndarray or PointOverlay

Point overlay for the static / animate paths. A 3-column array (value, row, col) draws unstyled points; pass a pyramids.plot.PointOverlay(...) to style them. Only forwarded when set — the multi-panel facet path has no points parameter and raises if one is supplied. Default is None.

None
color ColorScaling

Colour-scale spec pyramids.plot.ColorScaling (linear / power / sym-log / boundary / midpoint norm), e.g. ColorScaling.power(gamma=0.7). Default None.

None
contour Contour

Contour-line spec pyramids.plot.Contour(levels=…, labels=…, label_kw=…). Default None.

None
cells CellValues

Per-cell value annotation pyramids.plot.CellValues(show=…, size=…, background_threshold=…). Default None.

None
data_style DataStyle

Data-style / relief spec pyramids.plot.DataStyle(style=…, hillshade=…). Default None.

None
fig Figure

Draw into this figure instead of creating one. Pass it alongside ax; supplying fig on its own currently raises inside cleopatra (serapeum-org/cleopatra#326). Default is None.

None
ax Axes

Draw into these axes instead of creating them, so several variables can share one figure — e.g. a plt.subplots grid — while each panel keeps its georeferenced extent. An axes already carries its figure, so ax on its own is sufficient. Default is None.

None
**kwargs Unpack[PlotKwargs]

cleopatra's typed plot keywords, forwarded verbatim to :meth:Analysis.plot <pyramids.dataset.engines.Analysis.plot> — the xarray-aligned colour options (cmap / vmin / vmax / robust / extend), appearance (ax / figsize), and colorbar options. This mirrors :meth:Dataset.plot's **kwargs.

{}

Returns:

Name Type Description
ArrayGlyph

A cleopatra ArrayGlyph wrapping the rendered figure. Use the glyph's matplotlib handles (glyph.ax / glyph.fig / glyph.im) to decorate the plot further. In particular, to overlay a coastline (or borders / land / ocean / rivers / lakes) on top of the data, call cleopatra's reference helper on the axes — passing the CRS the data was plotted in (its own CRS — no reprojection needed) so the layer lines up::

from cleopatra.basemap.reference import add_features
var = nc.get_variable("t2m")
glyph = var.plot()
add_features(glyph.ax, "coastline", crs=var.epsg, zorder=5)

add_features fetches Natural Earth data (cached under ~/.cleopatra), so it needs the [viz] extra and network access on first use. A relief backdrop is available the same way via :func:cleopatra.basemap.reference.add_relief.

Raises:

Type Description
TypeError

If any of the Sentinel-only kwargs (rgb, surface_reflectance, cutoff, percentile, overview, overview_index) is passed. Each rejection message names the aligned replacement.

ValueError

If called on a root MDIM container without variable=, if variable= is passed on a subset and does not match the pinned variable name, if the resolved selectors do not pin to a single 2-D slice, or if axes.coords is malformed.

Examples:

  • Plot the first time step of a variable on a container. Tagged +SKIP because rendering requires the optional [viz] extra (cleopatra + matplotlib):
>>> import numpy as np
>>> from pyramids.netcdf import NetCDF, Selectors, GeoReference
>>> arr = np.random.rand(4, 8, 8).astype(np.float32)
>>> nc = NetCDF.from_array(
...     arr,
...     geo_ref=GeoReference(top_left_corner=(0, 0), cell_size=0.1, epsg=4326),
...     variable_name="t2m",
... )
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m", selectors=Selectors(isel={"time": 0}),
... )
  • Pick a time slice by label — the Selectors.time alias is equivalent to Selectors(sel={"time": value}):
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m", selectors=Selectors(time=2),
... )
  • Pin both time and level on a 4-D (time, pressure_level, lat, lon) variable. The selectors collapse both band dims to a single 2-D slice — equivalent to var.sel(time=12).sel(pressure_level=500):
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="temperature",
...     selectors=Selectors(time=12, level=500),
... )
  • Use an explicit sel dict instead of the convenience aliases — keys must match the variable's band-dim names:
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m", selectors=Selectors(sel={"time": 2}),
... )
  • Use an isel dict to address slices positionally. Each integer is mapped to the corresponding coord value via _band_dim_values_map:
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m", selectors=Selectors(isel={"time": 0}),
... )
  • All six Sentinel-only kwargs are rejected with a hint at the aligned replacement. These doctests run because the gate fires before any cleopatra import:
>>> nc.plot(variable="t2m", rgb=[0, 1, 2])  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
TypeError: ...rgb=...
>>> nc.plot(variable="t2m", surface_reflectance=10000)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
TypeError: ...surface_reflectance...
>>> nc.plot(variable="t2m", cutoff=[0.1, 0.9])  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
TypeError: ...cutoff...
>>> nc.plot(variable="t2m", percentile=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
TypeError: ...robust=True...
>>> nc.plot(variable="t2m", overview=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
TypeError: ...overview=...
>>> nc.plot(variable="t2m", overview_index=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
TypeError: ...overview_index=...
  • The legacy band= kwarg still works as an escape hatch but emits a :class:DeprecationWarning. Prefer Selectors(time=...) for new code:
>>> import warnings
>>> with warnings.catch_warnings(record=True) as caught:  # doctest: +SKIP
...     warnings.simplefilter("always")
...     cleo = nc.plot(variable="t2m", band=2)
>>> caught[0].category.__name__  # doctest: +SKIP
'DeprecationWarning'
  • Render a WRF-style curvilinear NetCDF on its real lat/lon grid. With 2-D XLAT / XLONG coord variables on the container, pyramids auto-detects them and routes the renderer to pcolormesh:
>>> cleo = nc.plot(variable="CANWAT", kind="pcolormesh")  # doctest: +SKIP
  • Pass an explicit curvilinear coord pair by variable name — useful when the variable has no CF coordinates attribute and the convention does not match WRF / ROMS / NEMO:
>>> from pyramids.netcdf import CoordinateSpec
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="CANWAT",
...     axes=CoordinateSpec(coords=("XLONG", "XLAT")),
... )
  • Pick a non-default render kind. "contourf" produces filled contours from the same data; "auto" (the default) picks pcolormesh when curvilinear coords are present, else falls back to imshow. Discrete contour levels live on a :class:~cleopatra.styling.params.Contour bag:
>>> from cleopatra.styling.params import Contour
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m",
...     kind="contourf",
...     contour=Contour(levels=10),
... )
  • Render with explicit 2-D coord arrays via axes=CoordinateSpec(coords=(x, y)). The arrays bypass the CF / convention auto-detection step and route the renderer to pcolormesh:
>>> import numpy as np
>>> x2d, y2d = np.meshgrid(
...     np.linspace(0, 10, 4), np.linspace(0, 10, 4),
... )
>>> arr = np.random.rand(3, 4, 4).astype(np.float32)
>>> nc_curv = NetCDF.from_array(
...     arr,
...     geo_ref=GeoReference(top_left_corner=(0, 0), cell_size=1.0, epsg=4326),
...     variable_name="t2m",
... )
>>> from pyramids.netcdf import CoordinateSpec
>>> cleo = nc_curv.plot(  # doctest: +SKIP
...     variable="t2m", axes=CoordinateSpec(coords=(x2d, y2d)),
... )
  • Robust (percentile-based) colour limits — clip to the 2nd / 98th percentile of the rendered slice. Colour controls are loose keywords, mirroring :meth:Dataset.plot:
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m",
...     cmap="viridis",
...     robust=True,
... )
  • Disable the colorbar with colorbar=False (cleopatra renders no bar), mirroring :meth:Dataset.plot:
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m", colorbar=False,
... )
  • Facet over the time dim. :class:FacetSpec lists the column dim (and optionally a row dim and a wrap value). The return type becomes :class:cleopatra.glyphs.gridded.array_glyph.FacetGrid:
>>> from pyramids.netcdf import FacetSpec
>>> grid = nc.plot(  # doctest: +SKIP
...     variable="t2m", facet=FacetSpec(col="time"),
... )
  • Facet a 4-D variable across both axes with col and row. col_wrap is ignored when row is given:
>>> grid = nc.plot(  # doctest: +SKIP
...     variable="temperature",
...     facet=FacetSpec(col="time", row="pressure_level"),
... )
  • Wrap a single-axis facet into a grid via col_wrap. N=4 panels with col_wrap=3 wrap to a 2x3 layout with one hidden slot:
>>> grid = nc.plot(  # doctest: +SKIP
...     variable="t2m", facet=FacetSpec(col="time", col_wrap=3),
... )
  • Faceting on a dim that is also pinned by a selector raises :class:ValueError before any I/O:
>>> nc.plot(  # doctest: +IGNORE_EXCEPTION_DETAIL
...     variable="t2m",
...     selectors=Selectors(time=0),
...     facet=FacetSpec(col="time"),
... )
Traceback (most recent call last):
    ...
ValueError: Cannot facet on 'time'...
  • Animate along the primary band dim with animate=True. The facade resolves the single free band dim (time here) and streams frames lazily via a per-frame data_getter so the animation never builds a 3-D stack:
>>> cleo = nc.plot(variable="t2m", animate=True)  # doctest: +SKIP
  • Name the animation dim explicitly. The string must match one of the variable's band-dim names. animate="time" is equivalent to animate=True when time is the only free band dim; the explicit form is required on variables with more than one free band dim:
>>> cleo = nc.plot(variable="t2m", animate="time")  # doctest: +SKIP
  • An unknown animate= dim name is rejected before any I/O. The error message lists the available band dims so typos are easy to spot:
>>> nc.plot(variable="t2m", animate="bogus")  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
KeyError: "`animate='bogus'` is not a band dim..."
  • Pinning a dim and then asking to animate over it raises :class:ValueError:
>>> nc.plot(  # doctest: +IGNORE_EXCEPTION_DETAIL
...     variable="t2m",
...     selectors=Selectors(time=0),
...     animate="time",
... )
Traceback (most recent call last):
    ...
ValueError: Cannot animate on 'time'...
  • Switch the static-plot path to a lazy dask read with chunks=. Only the rendered slice is materialised — useful when the variable is very large and a full eager read would waste memory:
>>> cleo = nc.plot(  # doctest: +SKIP
...     variable="t2m", chunks={"rows": 5, "cols": 5},
... )
Source code in src/pyramids/netcdf/netcdf.py
5320
5321
5322
5323
5324
5325
5326
5327
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
5366
5367
5368
5369
5370
5371
5372
5373
5374
5375
5376
5377
5378
5379
5380
5381
5382
5383
5384
5385
5386
5387
5388
5389
5390
5391
5392
5393
5394
5395
5396
5397
5398
5399
5400
5401
5402
5403
5404
5405
5406
5407
5408
5409
5410
5411
5412
5413
5414
5415
5416
5417
5418
5419
5420
5421
5422
5423
5424
5425
5426
5427
5428
5429
5430
5431
5432
5433
5434
5435
5436
5437
5438
5439
5440
5441
5442
5443
5444
5445
5446
5447
5448
5449
5450
5451
5452
5453
5454
5455
5456
5457
5458
5459
5460
5461
5462
5463
5464
5465
5466
5467
5468
5469
5470
5471
5472
5473
5474
5475
5476
5477
5478
5479
5480
5481
5482
5483
5484
5485
5486
5487
5488
5489
5490
5491
5492
5493
5494
5495
5496
5497
5498
5499
5500
5501
5502
5503
5504
5505
5506
5507
5508
5509
5510
5511
5512
5513
5514
5515
5516
5517
5518
5519
5520
5521
5522
5523
5524
5525
5526
5527
5528
5529
5530
5531
5532
5533
5534
5535
5536
5537
5538
5539
5540
5541
5542
5543
5544
5545
5546
5547
5548
5549
5550
5551
5552
5553
5554
5555
5556
5557
5558
5559
5560
5561
5562
5563
5564
5565
5566
5567
5568
5569
5570
5571
5572
5573
5574
5575
5576
5577
5578
5579
5580
5581
5582
5583
5584
5585
5586
5587
5588
5589
5590
5591
5592
5593
5594
5595
5596
5597
5598
5599
5600
5601
5602
5603
5604
5605
5606
5607
5608
5609
5610
5611
5612
5613
5614
5615
5616
5617
5618
5619
5620
5621
5622
5623
5624
5625
5626
5627
5628
5629
5630
5631
5632
5633
5634
5635
5636
5637
5638
5639
5640
5641
5642
5643
5644
5645
5646
5647
5648
5649
5650
5651
5652
5653
5654
5655
5656
5657
5658
5659
5660
5661
5662
5663
5664
5665
5666
5667
5668
5669
5670
5671
5672
5673
5674
5675
5676
5677
5678
5679
5680
5681
5682
5683
5684
5685
5686
5687
5688
5689
5690
5691
5692
5693
5694
5695
5696
5697
5698
5699
5700
5701
5702
5703
5704
5705
5706
5707
5708
5709
5710
5711
5712
5713
5714
5715
5716
5717
5718
5719
5720
5721
5722
5723
5724
5725
5726
5727
5728
5729
5730
5731
5732
5733
5734
5735
5736
5737
5738
5739
5740
5741
5742
5743
5744
5745
5746
5747
5748
5749
5750
5751
5752
5753
5754
5755
5756
5757
5758
5759
5760
5761
5762
5763
5764
5765
5766
5767
5768
5769
5770
5771
5772
5773
5774
5775
5776
5777
5778
5779
5780
5781
5782
5783
5784
5785
5786
5787
5788
5789
5790
5791
5792
5793
5794
5795
5796
5797
5798
5799
5800
5801
5802
5803
5804
5805
5806
5807
5808
5809
5810
5811
5812
5813
5814
5815
5816
5817
5818
5819
5820
5821
5822
5823
5824
5825
5826
5827
5828
5829
5830
5831
5832
5833
5834
5835
5836
5837
5838
5839
5840
5841
5842
5843
5844
5845
5846
5847
5848
5849
5850
5851
5852
5853
5854
5855
def plot(  # type: ignore[override]
    self,
    variable: str | None = None,
    *,
    selectors: Selectors | None = None,
    facet: FacetSpec | None = None,
    axes: CoordinateSpec | None = None,
    kind: str = "auto",
    animate: bool | str | None = None,
    chunks: Any | None = None,
    basemap: bool | str | dict[str, Any] | Basemap | None = None,
    exclude_value: Any | None = None,
    title: str | None = None,
    colorbar: bool | ColorBar | None = None,
    points: np.ndarray | PointOverlay | None = None,
    color: ColorScaling | None = None,
    contour: Contour | None = None,
    cells: CellValues | None = None,
    data_style: DataStyle | None = None,
    fig: Figure | None = None,
    ax: Axes | None = None,
    **kwargs: Unpack[PlotKwargs],
):
    """Plot a 2-D slice of a NetCDF variable using the plot vocabulary.

    The public surface is shaped around **variables** and **dimensions** — ``band``
    is not a NetCDF concept and has been removed from the signature. Variable
    selection is by name; the slice to render is pinned via a :class:`Selectors`
    option bag (``time`` / ``level`` / ``member`` / ``sel`` / ``isel`` / ``method``); multi-panel
    layout is described by a :class:`FacetSpec` bag (``col`` / ``row`` / ``col_wrap``);
    and the spatial-axis interpretation by a :class:`CoordinateSpec` bag (``coords`` /
    ``x_dim`` / ``y_dim``). Each is a frozen dataclass — construct it inline at the call
    site. Colour and the render options mirror :meth:`Dataset.plot`: pass ``cmap`` /
    ``vmin`` / ``vmax`` / ``robust`` / ``extend`` as loose keywords and the grouped
    ``color`` / ``colorbar`` / ``contour`` / ``cells`` / ``data_style`` bags (all
    cleopatra's), so no NetCDF-specific colour bag is needed. ``colorbar=False`` drops
    the colour bar.

    On a **root MDIM container** the ``variable=`` argument is required:

    ```python
    from pyramids.netcdf import NetCDF, Selectors
    nc.plot(variable="t2m", selectors=Selectors(time="2024-01-15"))
    ```

    On a **variable subset** (the result of :meth:`get_variable`) ``variable=``
    may be omitted or must equal the pinned variable name; otherwise the call
    is rejected, mirroring the :meth:`read_array` contract.

    Args:
        variable (str, optional):
            Name of the variable to plot. Required on the root MDIM container;
            must be ``None`` or equal to the pinned variable name on a subset.
            Defaults to None.
        selectors (Selectors, optional):
            Dim-selector bag. See :class:`Selectors` for the field list. A
            missing bag is treated as :class:`Selectors`\\ () (all fields
            ``None``). Defaults to None.
        facet (FacetSpec, optional):
            Faceting bag. See :class:`FacetSpec` for the field list. A missing
            bag (or one where both ``col`` and ``row`` are ``None``) routes
            the call to the single-panel static-plot path. Defaults to None.
        axes (CoordinateSpec, optional):
            Spatial-axis interpretation bag (:class:`CoordinateSpec`): an explicit
            curvilinear ``(x, y)`` coord pair (``coords=``), or the ``x_dim`` / ``y_dim``
            dimension names when they cannot be auto-resolved. ``coords`` accepts a
            length-2 sequence of variable-name strings (looked up on the parent
            container) or of numpy arrays (1-D matching the data x/y axis, or 2-D
            matching ``(rows, cols)``); when omitted, curvilinear coords are auto-detected
            from the CF ``coordinates`` attribute, then the well-known conventions (WRF
            ``XLAT``/``XLONG``, ROMS ``lat_rho``/``lon_rho``, NEMO ``nav_lat``/``nav_lon``),
            else the renderer falls back to ``extent=self.bbox`` (imshow). ``x_dim`` /
            ``y_dim`` set the lon/lat dimensions for variables whose axes aren't trailing
            and carry no CF metadata (e.g. CAM ``T(time, lat, lev, lon)``). Defaults to
            None.
        kind (str, optional):
            Render kind forwarded to cleopatra's ``ArrayGlyph.plot``.
            One of ``"auto"``, ``"imshow"``, ``"pcolormesh"``,
            ``"contour"``, ``"contourf"``. ``"auto"`` routes to
            ``pcolormesh`` when curvilinear ``coords`` are present,
            else ``imshow``. Defaults to ``"auto"``.
        animate (bool or str, optional):
            When set, render the variable as an animation across a
            band dim instead of a single 2-D slice. ``True`` animates
            along the variable's primary band dim (typically
            ``time``) — only valid when exactly one band dim remains
            after the selectors collapse the others. A string names
            the dim to animate along. ``None`` (default) returns a
            static plot. Mutually exclusive with faceting and with
            any selector that pins the animated dim. Defaults to
            None. On this animate path a
            ``frame_label=pyramids.plot.FrameLabel(...)`` (cleopatra
            >= 0.28) kwarg styles the per-frame caption — colour,
            size and placement — and is forwarded to cleopatra's
            animate call.
        chunks (Any, optional):
            Chunking spec forwarded to :meth:`read_array` for the
            static-plot path. ``None`` (default) preserves the eager
            read. Any of ``int`` / ``tuple`` / ``dict`` / ``"auto"``
            switches to the dask-backed lazy read and only the
            rendered slice is materialised. Has no effect on the
            ``animate=`` path. Defaults to None.
        basemap (bool, str, or Basemap, optional):
            Reference layer, dispatched by type. ``True`` or a named basemap
            tile provider overlays a pyramids web-tile basemap; a
            ``pyramids.plot.Basemap`` draws a relief/features
            layer instead (not supported on the faceted path). Defaults to None.
        exclude_value (Any, optional):
            Pixel value to mask out before plotting. Defaults to None.
        title (str, optional):
            Plot title. Defaults to None.
        colorbar (bool or ColorBar, optional):
            Colour-bar spec. A ``pyramids.plot.ColorBar(label=…, …)``
            draws a configured bar; ``False`` hides it and
            ``None`` uses cleopatra's default. Only forwarded when set. Default
            is ``None``.
        points (np.ndarray or PointOverlay, optional):
            Point overlay for the static / animate paths. A 3-column array
            ``(value, row, col)`` draws unstyled points; pass a
            ``pyramids.plot.PointOverlay(...)`` to style them. Only forwarded
            when set — the multi-panel ``facet`` path has no ``points``
            parameter and raises if one is supplied. Default is ``None``.
        color (ColorScaling, optional):
            Colour-scale spec ``pyramids.plot.ColorScaling`` (linear / power / sym-log /
            boundary / midpoint norm), e.g. ``ColorScaling.power(gamma=0.7)``. Default
            ``None``.
        contour (Contour, optional):
            Contour-line spec ``pyramids.plot.Contour(levels=…, labels=…, label_kw=…)``.
            Default ``None``.
        cells (CellValues, optional):
            Per-cell value annotation ``pyramids.plot.CellValues(show=…, size=…,
            background_threshold=…)``. Default ``None``.
        data_style (DataStyle, optional):
            Data-style / relief spec ``pyramids.plot.DataStyle(style=…, hillshade=…)``.
            Default ``None``.
        fig (matplotlib.figure.Figure, optional):
            Draw into this figure instead of creating one. Pass it alongside ``ax``;
            supplying ``fig`` on its own currently raises inside cleopatra
            (serapeum-org/cleopatra#326). Default is ``None``.
        ax (matplotlib.axes.Axes, optional):
            Draw into these axes instead of creating them, so several variables can
            share one figure — e.g. a ``plt.subplots`` grid — while each panel keeps
            its georeferenced extent. An axes already carries its figure, so ``ax`` on
            its own is sufficient. Default is ``None``.
        **kwargs (Unpack[PlotKwargs]):
            cleopatra's typed plot keywords, forwarded verbatim to
            :meth:`Analysis.plot <pyramids.dataset.engines.Analysis.plot>` — the
            xarray-aligned colour options (``cmap`` / ``vmin`` / ``vmax`` / ``robust`` /
            ``extend``), appearance (``ax`` / ``figsize``), and colorbar options. This
            mirrors :meth:`Dataset.plot`'s ``**kwargs``.

    Returns:
        ArrayGlyph: A cleopatra ``ArrayGlyph`` wrapping the rendered figure. Use the glyph's
            matplotlib handles (``glyph.ax`` / ``glyph.fig`` / ``glyph.im``) to decorate the
            plot further. In particular, to overlay a **coastline** (or borders / land / ocean /
            rivers / lakes) on top of the data, call cleopatra's reference helper on the axes —
            passing the CRS the data was plotted in (its own CRS — no reprojection needed) so the
            layer lines up::

                from cleopatra.basemap.reference import add_features
                var = nc.get_variable("t2m")
                glyph = var.plot()
                add_features(glyph.ax, "coastline", crs=var.epsg, zorder=5)

            ``add_features`` fetches Natural Earth data (cached under ``~/.cleopatra``), so it
            needs the ``[viz]`` extra and network access on first use. A relief backdrop is
            available the same way via :func:`cleopatra.basemap.reference.add_relief`.

    Raises:
        TypeError: If any of the Sentinel-only kwargs (``rgb``,
            ``surface_reflectance``, ``cutoff``, ``percentile``,
            ``overview``, ``overview_index``) is passed. Each
            rejection message names the aligned replacement.
        ValueError: If called on a root MDIM container without
            ``variable=``, if ``variable=`` is passed on a subset and
            does not match the pinned variable name, if the resolved
            selectors do not pin to a single 2-D slice, or if
            ``axes.coords`` is malformed.

    Examples:
        - Plot the first time step of a variable on a container. Tagged
          ``+SKIP`` because rendering requires the optional ``[viz]``
          extra (cleopatra + matplotlib):

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import NetCDF, Selectors, GeoReference
          >>> arr = np.random.rand(4, 8, 8).astype(np.float32)
          >>> nc = NetCDF.from_array(
          ...     arr,
          ...     geo_ref=GeoReference(top_left_corner=(0, 0), cell_size=0.1, epsg=4326),
          ...     variable_name="t2m",
          ... )
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", selectors=Selectors(isel={"time": 0}),
          ... )

          ```

        - Pick a time slice by label — the ``Selectors.time`` alias
          is equivalent to ``Selectors(sel={"time": value})``:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", selectors=Selectors(time=2),
          ... )

          ```

        - Pin both time and level on a 4-D ``(time, pressure_level,
          lat, lon)`` variable. The selectors collapse both band
          dims to a single 2-D slice — equivalent to
          ``var.sel(time=12).sel(pressure_level=500)``:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="temperature",
          ...     selectors=Selectors(time=12, level=500),
          ... )

          ```

        - Use an explicit ``sel`` dict instead of the convenience
          aliases — keys must match the variable's band-dim names:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", selectors=Selectors(sel={"time": 2}),
          ... )

          ```

        - Use an ``isel`` dict to address slices positionally. Each
          integer is mapped to the corresponding coord value via
          ``_band_dim_values_map``:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", selectors=Selectors(isel={"time": 0}),
          ... )

          ```

        - All six Sentinel-only kwargs are rejected with a hint at
          the aligned replacement. These doctests run because
          the gate fires before any cleopatra import:

          ```python
          >>> nc.plot(variable="t2m", rgb=[0, 1, 2])  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          TypeError: ...rgb=...

          ```

          ```python
          >>> nc.plot(variable="t2m", surface_reflectance=10000)  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          TypeError: ...surface_reflectance...

          ```

          ```python
          >>> nc.plot(variable="t2m", cutoff=[0.1, 0.9])  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          TypeError: ...cutoff...

          ```

          ```python
          >>> nc.plot(variable="t2m", percentile=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          TypeError: ...robust=True...

          ```

          ```python
          >>> nc.plot(variable="t2m", overview=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          TypeError: ...overview=...

          ```

          ```python
          >>> nc.plot(variable="t2m", overview_index=2)  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          TypeError: ...overview_index=...

          ```

        - The legacy ``band=`` kwarg still works as an escape hatch
          but emits a :class:`DeprecationWarning`. Prefer
          ``Selectors(time=...)`` for new code:

          ```python
          >>> import warnings
          >>> with warnings.catch_warnings(record=True) as caught:  # doctest: +SKIP
          ...     warnings.simplefilter("always")
          ...     cleo = nc.plot(variable="t2m", band=2)
          >>> caught[0].category.__name__  # doctest: +SKIP
          'DeprecationWarning'

          ```

        - Render a WRF-style curvilinear NetCDF on its real lat/lon
          grid. With 2-D ``XLAT`` / ``XLONG`` coord variables on the
          container, pyramids auto-detects them and routes the
          renderer to ``pcolormesh``:

          ```python
          >>> cleo = nc.plot(variable="CANWAT", kind="pcolormesh")  # doctest: +SKIP

          ```

        - Pass an explicit curvilinear coord pair by variable name —
          useful when the variable has no CF ``coordinates``
          attribute and the convention does not match
          WRF / ROMS / NEMO:

          ```python
          >>> from pyramids.netcdf import CoordinateSpec
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="CANWAT",
          ...     axes=CoordinateSpec(coords=("XLONG", "XLAT")),
          ... )

          ```

        - Pick a non-default render kind. ``"contourf"`` produces
          filled contours from the same data; ``"auto"`` (the
          default) picks ``pcolormesh`` when curvilinear coords are
          present, else falls back to ``imshow``. Discrete contour
          levels live on a :class:`~cleopatra.styling.params.Contour` bag:

          ```python
          >>> from cleopatra.styling.params import Contour
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m",
          ...     kind="contourf",
          ...     contour=Contour(levels=10),
          ... )

          ```

        - Render with explicit 2-D coord arrays via
          ``axes=CoordinateSpec(coords=(x, y))``. The arrays bypass the CF /
          convention auto-detection step and route the renderer to
          ``pcolormesh``:

          ```python
          >>> import numpy as np
          >>> x2d, y2d = np.meshgrid(
          ...     np.linspace(0, 10, 4), np.linspace(0, 10, 4),
          ... )
          >>> arr = np.random.rand(3, 4, 4).astype(np.float32)
          >>> nc_curv = NetCDF.from_array(
          ...     arr,
          ...     geo_ref=GeoReference(top_left_corner=(0, 0), cell_size=1.0, epsg=4326),
          ...     variable_name="t2m",
          ... )
          >>> from pyramids.netcdf import CoordinateSpec
          >>> cleo = nc_curv.plot(  # doctest: +SKIP
          ...     variable="t2m", axes=CoordinateSpec(coords=(x2d, y2d)),
          ... )

          ```

        - Robust (percentile-based) colour limits — clip to the 2nd / 98th
          percentile of the rendered slice. Colour controls are loose
          keywords, mirroring :meth:`Dataset.plot`:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m",
          ...     cmap="viridis",
          ...     robust=True,
          ... )

          ```

        - Disable the colorbar with ``colorbar=False`` (cleopatra renders
          no bar), mirroring :meth:`Dataset.plot`:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", colorbar=False,
          ... )

          ```

        - Facet over the time dim. :class:`FacetSpec` lists the
          column dim (and optionally a row dim and a wrap value).
          The return type becomes
          :class:`cleopatra.glyphs.gridded.array_glyph.FacetGrid`:

          ```python
          >>> from pyramids.netcdf import FacetSpec
          >>> grid = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", facet=FacetSpec(col="time"),
          ... )

          ```

        - Facet a 4-D variable across both axes with ``col`` and
          ``row``. ``col_wrap`` is ignored when ``row`` is given:

          ```python
          >>> grid = nc.plot(  # doctest: +SKIP
          ...     variable="temperature",
          ...     facet=FacetSpec(col="time", row="pressure_level"),
          ... )

          ```

        - Wrap a single-axis facet into a grid via ``col_wrap``.
          ``N=4`` panels with ``col_wrap=3`` wrap to a ``2x3``
          layout with one hidden slot:

          ```python
          >>> grid = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", facet=FacetSpec(col="time", col_wrap=3),
          ... )

          ```

        - Faceting on a dim that is also pinned by a selector
          raises :class:`ValueError` before any I/O:

          ```python
          >>> nc.plot(  # doctest: +IGNORE_EXCEPTION_DETAIL
          ...     variable="t2m",
          ...     selectors=Selectors(time=0),
          ...     facet=FacetSpec(col="time"),
          ... )
          Traceback (most recent call last):
              ...
          ValueError: Cannot facet on 'time'...

          ```

        - Animate along the primary band dim with ``animate=True``.
          The facade resolves the single free band dim (``time``
          here) and streams frames lazily via a per-frame
          ``data_getter`` so the animation never builds a 3-D stack:

          ```python
          >>> cleo = nc.plot(variable="t2m", animate=True)  # doctest: +SKIP

          ```

        - Name the animation dim explicitly. The string must match
          one of the variable's band-dim names. ``animate="time"``
          is equivalent to ``animate=True`` when ``time`` is the
          only free band dim; the explicit form is required on
          variables with more than one free band dim:

          ```python
          >>> cleo = nc.plot(variable="t2m", animate="time")  # doctest: +SKIP

          ```

        - An unknown ``animate=`` dim name is rejected before any
          I/O. The error message lists the available band dims so
          typos are easy to spot:

          ```python
          >>> nc.plot(variable="t2m", animate="bogus")  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
              ...
          KeyError: "`animate='bogus'` is not a band dim..."

          ```

        - Pinning a dim and then asking to animate over it
          raises :class:`ValueError`:

          ```python
          >>> nc.plot(  # doctest: +IGNORE_EXCEPTION_DETAIL
          ...     variable="t2m",
          ...     selectors=Selectors(time=0),
          ...     animate="time",
          ... )
          Traceback (most recent call last):
              ...
          ValueError: Cannot animate on 'time'...

          ```

        - Switch the static-plot path to a lazy dask read with
          ``chunks=``. Only the rendered slice is materialised —
          useful when the variable is very large and a full eager
          read would waste memory:

          ```python
          >>> cleo = nc.plot(  # doctest: +SKIP
          ...     variable="t2m", chunks={"rows": 5, "cols": 5},
          ... )

          ```
    """
    # ``colorbar``/``points`` and the typed render groups (``color`` / ``contour`` /
    # ``cells`` / ``data_style``) are hoisted to explicit params for parity with
    # ``Dataset.plot``. Spread the explicitly-set ones as their own ``**`` — kept in a
    # separate dict, not merged into the typed ``**kwargs`` (whose ``PlotKwargs``
    # TypedDict has no group keys) — so the multi-mode render path consumes them.
    # Only inject when set: the facet path rejects ``points`` outright, so a default
    # ``None`` must not reach it, and an unset group must not override cleopatra's
    # backend default for that group.
    group_kwargs = nonnull_group_kwargs(
        color=color, contour=contour, cells=cells, data_style=data_style
    )
    if colorbar is not None:
        group_kwargs["colorbar"] = colorbar
    if points is not None:
        group_kwargs["points"] = points
    return NetCDFPlot(self).run(
        variable,
        selectors=selectors,
        facet=facet,
        axes=axes,
        kind=kind,
        animate=animate,
        chunks=chunks,
        basemap=basemap,
        exclude_value=exclude_value,
        title=title,
        fig=fig,
        ax=ax,
        **group_kwargs,
        **kwargs,
    )

read_array(variable=None, band=None, window=None, unpack=True, *, bbox=None, epsg=None, chunks=None, lock=None, masked=False, bbox_rounding='cover', squeeze=False) #

Read array from the dataset (eager by default, lazy with chunks).

A CF-packed variable (scale_factor / add_offset) is read in physical units unless unpack=False asks for the stored counts. Its no-data / _FillValue sentinel stays a stored value, so either compare it against an unpack=False read or let masked=True build the mask.

Parameters:

Name Type Description Default
variable str | None

When this instance is a root MDIM container, the variable name to read. When the instance is already a variable subset (nc.get_variable("x")) this argument must be None — the variable is already pinned.

None
band int | None

Band index to read, or None for all bands. Only honored on the eager path (chunks=None).

None
window list[int] | None

Spatial window to read. Only honored on the eager path. Mutually exclusive with bbox.

None
unpack bool

Apply the variable's CF packing, real = raw * scale + offset, from its scale_factor and/or add_offset. Defaults to True (#1124); pass False for the stored counts. The pair is the one _effective_packing resolves: the variable's own scale_factor / add_offset when it declares them, else the classic band's (a container opened with open_as_multi_dimensional=False). The eager and lazy paths apply the same pair exactly once, and a packed result is float64. A variable that declares neither is returned unchanged, same dtype. Applied lazily via :mod:dask.array arithmetic when chunks is given — the compute graph stays lazy until the caller materializes it.

True
bbox keyword - only

(west, south, east, north) quadruple in the CRS named by epsg. Internally wrapped in a one-row :class:pyramids.feature.FeatureCollection via :meth:pyramids.feature.FeatureCollection.from_bbox and routed through the same window path. Honored on the eager path only — same constraint as window. Mutually exclusive with window; combining with chunks raises :class:ValueError (mirroring :class:pyramids.dataset.engines.IO.read_array's chunks= + window= rule).

None
epsg keyword - only

CRS for bbox — anything geopandas accepts for crs= (EPSG int, "EPSG:4326", WKT, :class:pyproj.CRS). Defaults to the dataset's own CRS, so a bbox in the dataset's native CRS needs no extra argument.

None
chunks Any

Chunking spec for a lazy return. None (the default) returns an eager :class:numpy.ndarray and preserves the legacy behavior. Any of int, tuple, dict, or the string "auto" switches to a :class:dask.array.Array backed by MDArray chunk reads. Defaults chunked at the variable's native GetBlockSize (see :attr:pyramids.netcdf.models.VariableInfo.block_size); a conservative (1,..., rows, cols) fallback is used when the driver doesn't advertise one.

None
lock Any

Lock passed to the underlying :class:pyramids.base._file_manager.CachingFileManager. None → :func:pyramids.base._locks.default_lock (a :class:SerializableLock, or a dask.distributed.Lock when a client is active). False → :class:pyramids.base._locks.DummyLock. Only meaningful when chunks is not None.

None
masked bool

When True, return a :class:numpy.ma.MaskedArray with the variable's no-data / fill cells masked. On the eager path the mask is built from the raw stored values before any unpack scaling; on the lazy path (chunks=) the same cells are masked after unpack, against the physical sentinel. The lazy result (a lazy dask masked array until computed) carries the identical mask and the identical unmasked physical values as the eager read, and filled(...) agrees cell for cell. Only the raw data stored under a masked cell may differ between the two — the eager path keeps the stored sentinel there, the lazy path the unpacked one — which is immaterial to a masked array (every reduction, comparison and filled() ignores masked positions). Default is False.

False
bbox_rounding keyword - only

How a bbox (or geometry window) is snapped to whole pixels — "cover" (default; floor/ceil so every overlapping pixel is kept) or "nearest" (round each edge to the closest pixel boundary, the tightest window). Forwarded verbatim to Dataset.read_array; ignored on the lazy path and for pixel windows. Any other value raises ValueError.

'cover'
squeeze keyword - only

How the non-spatial (band) dimensions shape the result, making the eager and lazy paths interchangeable (#1226, #1241). False (default) returns the dimension-preserving (*band_sizes, rows, cols) on both paths (the faithful, xarray-like layout) — a size-1 band axis is kept, not squeezed. True returns the classic raster (bands, rows, cols) flatten (every band dimension collapsed into one row-major axis, a singleton squeezed to 2-D) on both paths. Either way the eager and lazy reads return the identical shape. The default flipped to dimension-preserving in #1241 (previously the eager path flattened + squeezed); pass squeeze=True for the legacy classic-raster layout. On a band= read (a single plane) it has no effect.

False

Returns:

Type Description
ArrayLike

np.ndarray or dask.array.Array: The array data, eager

ArrayLike

(numpy) by default or lazy (dask) when chunks is

ArrayLike

supplied, in physical float64 units when unpack=True

ArrayLike

and the variable is packed. The lazy array computes

ArrayLike

chunk-by-chunk through

ArrayLike

md_arr.ReadAsArray(array_start_idx=starts, count=counts).

Raises:

Type Description
ValueError

If called on a root MDIM container without a variable argument, when a subset is called with a conflicting variable name, when both window and bbox are supplied, or when both chunks and bbox are supplied (the lazy path doesn't yet honour bbox windowing — matching :class:pyramids.dataset.engines.IO.read_array's chunks= + window= rule).

ImportError

If chunks is given but dask is not installed. Install the [lazy] extra.

Note

Two limitations are specific to the lazy (chunks) path:

  • Open-handle lifetime. A lazy read parks a live GDAL handle in the process-global pyramids.base._file_manager.FILE_CACHE (via CachingFileManager) so later chunk reads can reuse it. It is released two ways (#727): when the lazy read's result — and every array derived from it (unpack=True, an open_mfdataset stack, a plot(chunks=), a slice or dask-arithmetic result) — is dropped or garbage-collected (a weakref.finalize on the manager, kept alive by the chunk readers in the dask graph, evicts and closes it); and when this NetCDF's close() is called, which evicts the handles its lazy reads parked even while a lazy array from it is still alive. It is also released under LRU pressure or at interpreter exit. A lazy array stays usable after close() — its manager re-opens the handle on the next chunk read.

Opening the same file again in the same process while a handle is still parked would leave two live GDAL handles to one NetCDF, which can crash GDAL on Windows. read_file now guards against this (#1224): it releases any parked handle for the path before reopening and warns, and a lazy array still referencing the file re-opens transparently on its next chunk read. To avoid the warning (and re-parking a second handle during continued concurrent use), drop the lazy array(s) or close() the NetCDF before reopening. * Axis plane and shape. The lazy path resolves the raster plane the same way the eager path does — explicit x_dim / y_dim (carried on the get_variable subset) or CF detection, falling back to the trailing two dimensions — and moves it to the trailing two axes, north-up / west-first, so a lazy read is oriented on the same plane as the eager read (#728). It reads the entire source variable in storage order and does not apply a prior sel / crop / window carried on the subset (those materialize the eager raster only): slice the returned dask array yourself, or read a selected / cropped subset eagerly. For a subset with no active selection the eager and lazy reads return the identical dimension-preserving shape by default (squeeze=False, #1241): both keep one leading axis per non-spatial dimension in storage order. Pass squeeze=True for the classic flattened (bands, rows, cols) layout (a singleton squeezed to 2-D) on both paths. Chunking is computed in storage order (before the plane is moved to the trailing axes), so for a non-trailing plane the default chunks="auto" may split the resolved plane finely and the named chunks= aliases (rows / cols) map to storage axes, not the resolved plane — pass explicit integer chunks= per axis for optimal chunking on such a variable.

Examples:

  • Eager bbox read on a root container — the container auto-routes to the named variable. The noah fixture's geotransform is cell_size=0.5°, origin=(0, 90), 512×512 cells — so its coordinate range is x ∈ [0, 256) and y ∈ (-166, 90]. The bbox below sits well inside that range:
    >>> from pyramids.netcdf import NetCDF
    >>> nc = NetCDF.read_file(
    ...     "tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc"
    ... )
    >>> arr = nc.read_array(
    ...     variable="Band1",
    ...     bbox=(10.0, -50.0, 50.0, -20.0),
    ... )
    >>> arr.ndim in (2, 3)
    True
    
  • A packed variable reads in physical units by default. The fixture's z is stored in [-100, 100] with scale_factor=0.01 and add_offset=1.5:
    >>> from pyramids.netcdf import NetCDF
    >>> nc = NetCDF.read_file(
    ...     "tests/data/netcdf/coards__4v__1d2-2d2__scaleoffset__y-asc.nc",
    ...     open_as_multi_dimensional=True,
    ... )
    >>> z = nc.get_variable("z")
    >>> float(z.read_array(band=0, unpack=False).max())
    100.0
    >>> physical = z.read_array(band=0)
    >>> float(physical.max()), physical.dtype
    (2.5, dtype('float64'))
    >>> float(nc.read_array("z", band=0).min())
    0.5
    
See Also
  • :meth:pyramids.dataset.Dataset.read_array: the same bbox= / epsg= surface for plain rasters.
  • :meth:crop: clip the whole dataset by bbox.
Source code in src/pyramids/netcdf/netcdf.py
def read_array(  # type: ignore[override]
    self,
    variable: str | None = None,
    band: int | None = None,
    window: list[int] | None = None,
    unpack: bool = True,
    *,
    bbox: tuple[float, float, float, float] | list[float] | None = None,
    epsg: Any = None,
    chunks: Any = None,
    lock: Any = None,
    masked: bool = False,
    bbox_rounding: str = "cover",
    squeeze: bool = False,
) -> ArrayLike:
    """Read array from the dataset (eager by default, lazy with `chunks`).

    A CF-packed variable (`scale_factor` / `add_offset`) is read in physical units
    unless `unpack=False` asks for the stored counts. Its no-data / `_FillValue`
    sentinel stays a **stored** value, so either compare it against an `unpack=False`
    read or let `masked=True` build the mask.

    Args:
        variable: When this instance is a root MDIM container,
            the variable name to read. When the instance is
            already a variable subset (`nc.get_variable("x")`)
            this argument must be `None` — the variable is
            already pinned.
        band: Band index to read, or None for all bands. Only
            honored on the eager path (`chunks=None`).
        window: Spatial window to read. Only honored on the
            eager path. Mutually exclusive with `bbox`.
        unpack: Apply the variable's CF packing,
            `real = raw * scale + offset`, from its `scale_factor`
            and/or `add_offset`. **Defaults to True** (#1124); pass
            `False` for the stored counts. The pair is the one
            `_effective_packing` resolves: the variable's own
            `scale_factor` / `add_offset` when it declares them, else
            the classic band's (a container opened with
            `open_as_multi_dimensional=False`). The eager and lazy
            paths apply the same pair exactly once, and a packed
            result is `float64`. A variable that declares neither is
            returned unchanged, same dtype.
            Applied lazily via :mod:`dask.array` arithmetic when
            `chunks` is given — the compute graph stays lazy
            until the caller materializes it.
        bbox (keyword-only): `(west, south, east, north)` quadruple
            in the CRS named by `epsg`. Internally wrapped in a
            one-row :class:`pyramids.feature.FeatureCollection` via
            :meth:`pyramids.feature.FeatureCollection.from_bbox`
            and routed through the same window path. Honored on
            the **eager path only** — same constraint as `window`.
            Mutually exclusive with `window`; combining with
            `chunks` raises :class:`ValueError` (mirroring
            :class:`pyramids.dataset.engines.IO.read_array`'s
            `chunks=` + `window=` rule).
        epsg (keyword-only): CRS for `bbox` — anything geopandas
            accepts for `crs=` (EPSG int, `"EPSG:4326"`, WKT,
            :class:`pyproj.CRS`). Defaults to the dataset's own
            CRS, so a bbox in the dataset's native CRS needs no
            extra argument.
        chunks: Chunking spec for a lazy return. `None` (the
            default) returns an eager :class:`numpy.ndarray` and
            preserves the legacy behavior. Any of `int`,
            `tuple`, `dict`, or the string `"auto"` switches
            to a :class:`dask.array.Array` backed by MDArray
            chunk reads. Defaults chunked at the variable's
            native `GetBlockSize` (see
            :attr:`pyramids.netcdf.models.VariableInfo.block_size`);
            a conservative `(1,..., rows, cols)` fallback is
            used when the driver doesn't advertise one.
        lock: Lock passed to the underlying
            :class:`pyramids.base._file_manager.CachingFileManager`.
            `None` → :func:`pyramids.base._locks.default_lock`
            (a :class:`SerializableLock`, or a
            `dask.distributed.Lock` when a client is active).
            `False` → :class:`pyramids.base._locks.DummyLock`.
            Only meaningful when `chunks` is not `None`.
        masked: When `True`, return a :class:`numpy.ma.MaskedArray`
            with the variable's no-data / fill cells masked. On the
            eager path the mask is built from the raw stored values
            before any `unpack` scaling; on the lazy path (`chunks=`)
            the same cells are masked after unpack, against the
            physical sentinel. The lazy result (a lazy `dask` masked
            array until computed) carries the **identical mask** and the
            **identical unmasked physical values** as the eager read, and
            `filled(...)` agrees cell for cell. Only the raw data stored
            *under* a masked cell may differ between the two — the eager
            path keeps the stored sentinel there, the lazy path the
            unpacked one — which is immaterial to a masked array (every
            reduction, comparison and `filled()` ignores masked
            positions). Default is `False`.
        bbox_rounding (keyword-only): How a `bbox` (or geometry
            `window`) is snapped to whole pixels — `"cover"`
            (default; floor/ceil so every overlapping pixel is kept)
            or `"nearest"` (round each edge to the closest pixel
            boundary, the tightest window). Forwarded verbatim to
            `Dataset.read_array`; ignored on the lazy path and for
            pixel windows. Any other value raises `ValueError`.
        squeeze (keyword-only): How the non-spatial (band) dimensions
            shape the result, making the eager and lazy paths
            interchangeable (#1226, #1241). `False` (default) returns
            the **dimension-preserving** `(*band_sizes, rows, cols)` on
            **both** paths (the faithful, xarray-like layout) — a size-1
            band axis is kept, not squeezed. `True` returns the classic
            raster `(bands, rows, cols)` flatten (every band dimension
            collapsed into one row-major axis, a singleton squeezed to
            2-D) on **both** paths. Either way the eager and lazy reads
            return the identical shape. The default flipped to
            dimension-preserving in #1241 (previously the eager path
            flattened + squeezed); pass `squeeze=True` for the legacy
            classic-raster layout. On a `band=` read (a single plane)
            it has no effect.

    Returns:
        np.ndarray or dask.array.Array: The array data, eager
        (numpy) by default or lazy (dask) when `chunks` is
        supplied, in physical `float64` units when `unpack=True`
        and the variable is packed. The lazy array computes
        chunk-by-chunk through
        `md_arr.ReadAsArray(array_start_idx=starts, count=counts)`.

    Raises:
        ValueError: If called on a root MDIM container without a
            `variable` argument, when a subset is called with a
            conflicting `variable` name, when both `window` and
            `bbox` are supplied, or when both `chunks` and
            `bbox` are supplied (the lazy path doesn't yet
            honour bbox windowing — matching
            :class:`pyramids.dataset.engines.IO.read_array`'s
            `chunks=` + `window=` rule).
        ImportError: If `chunks` is given but `dask` is not
            installed. Install the `[lazy]` extra.

    Note:
        Two limitations are specific to the lazy (`chunks`) path:

        * **Open-handle lifetime.** A lazy read parks a live GDAL handle in the process-global
          `pyramids.base._file_manager.FILE_CACHE` (via `CachingFileManager`) so later chunk reads
          can reuse it. It is released two ways (#727): when the lazy read's result — **and every
          array derived from it** (`unpack=True`, an `open_mfdataset` stack, a `plot(chunks=)`, a
          slice or dask-arithmetic result) — is dropped or garbage-collected (a `weakref.finalize`
          on the manager, kept alive by the chunk readers in the dask graph, evicts and closes it);
          **and** when this `NetCDF`'s `close()` is called, which evicts the handles its lazy reads
          parked even while a lazy array from it is still alive. It is also released under LRU
          pressure or at interpreter exit. A lazy array stays usable after `close()` — its manager
          re-opens the handle on the next chunk read.

          Opening the **same file again in the same process while a handle is still parked** would
          leave two live GDAL handles to one NetCDF, which can crash GDAL on Windows. `read_file`
          now guards against this (#1224): it releases any parked handle for the path before
          reopening and warns, and a lazy array still referencing the file re-opens transparently
          on its next chunk read. To avoid the warning (and re-parking a second handle during
          continued concurrent use), **drop the lazy array(s)** or **`close()` the `NetCDF`**
          before reopening.
        * **Axis plane and shape.** The lazy path resolves the raster plane the same way the
          eager path does — explicit `x_dim` / `y_dim` (carried on the `get_variable` subset) or
          CF detection, falling back to the trailing two dimensions — and moves it to the
          trailing two axes, north-up / west-first, so a lazy read is oriented on the *same*
          plane as the eager read (#728). It reads the **entire source variable** in storage
          order and does **not** apply a prior `sel` / `crop` / `window` carried on the subset
          (those materialize the eager raster only): slice the returned dask array yourself, or
          read a selected / cropped subset eagerly. For a subset with no active selection the
          eager and lazy reads return the identical dimension-preserving shape by default
          (`squeeze=False`, #1241): both keep one leading axis per non-spatial dimension in
          storage order. Pass `squeeze=True` for the classic flattened `(bands, rows, cols)`
          layout (a singleton squeezed to 2-D) on both paths. Chunking is computed in *storage* order
          (before the plane is moved to the trailing axes), so for a non-trailing plane the
          default `chunks="auto"` may split the resolved plane finely and the named `chunks=`
          aliases (`rows` / `cols`) map to storage axes, not the resolved plane — pass explicit
          integer `chunks=` per axis for optimal chunking on such a variable.

    Examples:
        - Eager bbox read on a root container — the container
          auto-routes to the named variable. The noah fixture's
          geotransform is `cell_size=0.5°`, `origin=(0, 90)`,
          512×512 cells — so its coordinate range is
          `x ∈ [0, 256)` and `y ∈ (-166, 90]`. The bbox
          below sits well inside that range:
            ```python
            >>> from pyramids.netcdf import NetCDF
            >>> nc = NetCDF.read_file(
            ...     "tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc"
            ... )
            >>> arr = nc.read_array(
            ...     variable="Band1",
            ...     bbox=(10.0, -50.0, 50.0, -20.0),
            ... )
            >>> arr.ndim in (2, 3)
            True

            ```
        - A packed variable reads in physical units by default. The
          fixture's `z` is stored in `[-100, 100]` with
          `scale_factor=0.01` and `add_offset=1.5`:
            ```python
            >>> from pyramids.netcdf import NetCDF
            >>> nc = NetCDF.read_file(
            ...     "tests/data/netcdf/coards__4v__1d2-2d2__scaleoffset__y-asc.nc",
            ...     open_as_multi_dimensional=True,
            ... )
            >>> z = nc.get_variable("z")
            >>> float(z.read_array(band=0, unpack=False).max())
            100.0
            >>> physical = z.read_array(band=0)
            >>> float(physical.max()), physical.dtype
            (2.5, dtype('float64'))
            >>> float(nc.read_array("z", band=0).min())
            0.5

            ```

    See Also:
        - :meth:`pyramids.dataset.Dataset.read_array`: the same
          `bbox=` / `epsg=` surface for plain rasters.
        - :meth:`crop`: clip the whole dataset by bbox.
    """
    read_window = self._resolve_bbox_to_window(window, bbox, epsg, chunks)
    is_container = self._is_root_container
    if is_container:
        if variable is None:
            self._check_not_container("read_array")
        subset = self.get_variable(cast("str", variable))
        if not isinstance(subset, NetCDF):
            # `get_variable` hands back a `LabeledArray` for anything
            # GDAL cannot expose as a raster -- a 1-D array (`time_bounds`,
            # `band_id`, a grouped store's `flight_03/CO`) or a non-numeric
            # one. `variable_names` enumerates several such arrays on the
            # GOES fixture, so this is a name the container advertises;
            # delegating blindly answered it with
            # `AttributeError: 'MDArray' object has no attribute
            # 'read_array'`, which reads as a pyramids bug rather than a
            # shape mismatch.
            return self._read_non_raster_variable(
                cast("str", variable),
                band,
                read_window,
                chunks,
                masked,
                unpack,
                subset,
            )
        return subset.read_array(
            band=band,
            window=read_window,
            unpack=unpack,
            chunks=chunks,
            lock=lock,
            masked=masked,
            bbox_rounding=bbox_rounding,
            squeeze=squeeze,
        )
    if variable is not None and variable != self._source_var_name:
        raise ValueError(
            f"This NetCDF instance is already pinned to variable "
            f"{self._source_var_name!r}; cannot re-read as "
            f"{variable!r}. Call read_array on the parent container "
            "instead."
        )
    if chunks is None:
        result = self._read_array_eager(
            band, read_window, masked, bbox_rounding, unpack
        )
        # squeeze=False (the default, #1241) converges the eager shape on the lazy one: undo
        # GDAL's row-major band flatten and the singleton squeeze so a full read returns the
        # dimension-preserving (*band_sizes, rows, cols) (#1226). Only for a full read; a
        # band= read already selects a single plane.
        if not squeeze and band is None:
            result = self._preserve_band_dims(result)
    else:
        result = self._read_array_lazy_processed(
            chunks, lock, unpack, masked, squeeze, read_window
        )
    return cast(ArrayLike, result)

crop(*args, **kwargs) #

Facade — :meth:Selection.crop <pyramids.netcdf.engines.selection.Selection.crop>.

Source code in src/pyramids/netcdf/netcdf.py
def crop(self, *args, **kwargs) -> NetCDF:
    """Facade — :meth:`Selection.crop <pyramids.netcdf.engines.selection.Selection.crop>`."""
    return self.selection.crop(*args, **kwargs)

reduce(dim, how='mean', *, groupby=None, skipna=True, q=None) #

Facade — :meth:Selection.reduce <pyramids.netcdf.engines.selection.Selection.reduce>.

Source code in src/pyramids/netcdf/netcdf.py
def reduce(
    self,
    dim: str,
    how: str = "mean",
    *,
    groupby: list | tuple | str | None = None,
    skipna: bool = True,
    q: float | None = None,
) -> NetCDF:
    """Facade — :meth:`Selection.reduce <pyramids.netcdf.engines.selection.Selection.reduce>`."""
    return self.selection.reduce(dim, how, groupby=groupby, skipna=skipna, q=q)

coarsen(dim, window, *, how='mean', boundary='exact', skipna=True, q=None) #

Facade — :meth:Selection.coarsen <pyramids.netcdf.engines.selection.Selection.coarsen>.

Source code in src/pyramids/netcdf/netcdf.py
def coarsen(
    self,
    dim: str,
    window: int,
    *,
    how: str = "mean",
    boundary: str = "exact",
    skipna: bool = True,
    q: float | None = None,
) -> NetCDF:
    """Facade — :meth:`Selection.coarsen <pyramids.netcdf.engines.selection.Selection.coarsen>`."""
    return self.selection.coarsen(
        dim, window, how=how, boundary=boundary, skipna=skipna, q=q
    )

groupby_bins(dim, bins, how='mean', *, right=True, include_lowest=False, skipna=True, q=None) #

Facade — :meth:Selection.groupby_bins <pyramids.netcdf.engines.selection.Selection.groupby_bins>.

Source code in src/pyramids/netcdf/netcdf.py
def groupby_bins(
    self,
    dim: str,
    bins: int | Sequence[float],
    how: str = "mean",
    *,
    right: bool = True,
    include_lowest: bool = False,
    skipna: bool = True,
    q: float | None = None,
) -> NetCDF:
    """Facade — :meth:`Selection.groupby_bins <pyramids.netcdf.engines.selection.Selection.groupby_bins>`."""
    return self.selection.groupby_bins(
        dim,
        bins,
        how,
        right=right,
        include_lowest=include_lowest,
        skipna=skipna,
        q=q,
    )

rolling(dim, window, *, how='mean', center=False, min_periods=None, q=None) #

Facade — :meth:Selection.rolling <pyramids.netcdf.engines.selection.Selection.rolling>.

Source code in src/pyramids/netcdf/netcdf.py
def rolling(
    self,
    dim: str,
    window: int,
    *,
    how: str = "mean",
    center: bool = False,
    min_periods: int | None = None,
    q: float | None = None,
) -> NetCDF:
    """Facade — :meth:`Selection.rolling <pyramids.netcdf.engines.selection.Selection.rolling>`."""
    return self.selection.rolling(
        dim, window, how=how, center=center, min_periods=min_periods, q=q
    )

diff(dim, n=1, *, label='upper') #

Facade — :meth:Selection.diff <pyramids.netcdf.engines.selection.Selection.diff>.

Source code in src/pyramids/netcdf/netcdf.py
def diff(self, dim: str, n: int = 1, *, label: str = "upper") -> NetCDF:
    """Facade — :meth:`Selection.diff <pyramids.netcdf.engines.selection.Selection.diff>`."""
    return self.selection.diff(dim, n, label=label)

rank(dim, *, pct=False) #

Facade — :meth:Selection.rank <pyramids.netcdf.engines.selection.Selection.rank>.

Source code in src/pyramids/netcdf/netcdf.py
def rank(self, dim: str, *, pct: bool = False) -> NetCDF:
    """Facade — :meth:`Selection.rank <pyramids.netcdf.engines.selection.Selection.rank>`."""
    return self.selection.rank(dim, pct=pct)

pad(*, mode='constant', constant_values=None, **pad_width) #

Facade — :meth:Selection.pad <pyramids.netcdf.engines.selection.Selection.pad>.

Source code in src/pyramids/netcdf/netcdf.py
def pad(
    self, *, mode: str = "constant", constant_values: Any = None, **pad_width: Any
) -> NetCDF:
    """Facade — :meth:`Selection.pad <pyramids.netcdf.engines.selection.Selection.pad>`."""
    return self.selection.pad(
        mode=mode, constant_values=constant_values, **pad_width
    )

transpose(*dims) #

Facade — :meth:Selection.transpose <pyramids.netcdf.engines.selection.Selection.transpose>.

Source code in src/pyramids/netcdf/netcdf.py
def transpose(self, *dims: Any) -> NetCDF:
    """Facade — :meth:`Selection.transpose <pyramids.netcdf.engines.selection.Selection.transpose>`."""
    return self.selection.transpose(*dims)

interp(method='linear', **coords) #

Facade — :meth:Selection.interp <pyramids.netcdf.engines.selection.Selection.interp>.

Source code in src/pyramids/netcdf/netcdf.py
def interp(self, method: str = "linear", **coords: Any) -> NetCDF:
    """Facade — :meth:`Selection.interp <pyramids.netcdf.engines.selection.Selection.interp>`."""
    return self.selection.interp(method, **coords)

interp_like(other, method='linear') #

Facade — :meth:Selection.interp_like <pyramids.netcdf.engines.selection.Selection.interp_like>.

Source code in src/pyramids/netcdf/netcdf.py
def interp_like(self, other: NetCDF, method: str = "linear") -> NetCDF:
    """Facade — :meth:`Selection.interp_like <pyramids.netcdf.engines.selection.Selection.interp_like>`."""
    return self.selection.interp_like(other, method=method)

convert_calendar(calendar, *, dim='time', align_on='date') #

Facade — :meth:Selection.convert_calendar <pyramids.netcdf.engines.selection.Selection.convert_calendar>.

Source code in src/pyramids/netcdf/netcdf.py
def convert_calendar(
    self, calendar: str, *, dim: str = "time", align_on: str = "date"
) -> NetCDF:
    """Facade — :meth:`Selection.convert_calendar
    <pyramids.netcdf.engines.selection.Selection.convert_calendar>`."""
    return self.selection.convert_calendar(calendar, dim=dim, align_on=align_on)

interp_calendar(target, *, dim='time') #

Facade — :meth:Selection.interp_calendar <pyramids.netcdf.engines.selection.Selection.interp_calendar>.

Source code in src/pyramids/netcdf/netcdf.py
def interp_calendar(self, target: NetCDF, *, dim: str = "time") -> NetCDF:
    """Facade — :meth:`Selection.interp_calendar
    <pyramids.netcdf.engines.selection.Selection.interp_calendar>`."""
    return self.selection.interp_calendar(target, dim=dim)

differentiate(dim) #

Facade — :meth:Selection.differentiate <pyramids.netcdf.engines.selection.Selection.differentiate>.

Source code in src/pyramids/netcdf/netcdf.py
def differentiate(self, dim: str) -> NetCDF:
    """Facade — :meth:`Selection.differentiate <pyramids.netcdf.engines.selection.Selection.differentiate>`."""
    return self.selection.differentiate(dim)

integrate(dim) #

Facade — :meth:Selection.integrate <pyramids.netcdf.engines.selection.Selection.integrate>.

Source code in src/pyramids/netcdf/netcdf.py
def integrate(self, dim: str) -> NetCDF:
    """Facade — :meth:`Selection.integrate <pyramids.netcdf.engines.selection.Selection.integrate>`."""
    return self.selection.integrate(dim)

cumulative_integrate(dim) #

Facade — :meth:Selection.cumulative_integrate <pyramids.netcdf.engines.selection.Selection.cumulative_integrate>.

Source code in src/pyramids/netcdf/netcdf.py
def cumulative_integrate(self, dim: str) -> NetCDF:
    """Facade — :meth:`Selection.cumulative_integrate
    <pyramids.netcdf.engines.selection.Selection.cumulative_integrate>`."""
    return self.selection.cumulative_integrate(dim)

polyfit(dim, deg) #

Facade — :meth:Selection.polyfit <pyramids.netcdf.engines.selection.Selection.polyfit>.

Source code in src/pyramids/netcdf/netcdf.py
def polyfit(self, dim: str, deg: int) -> NetCDF:
    """Facade — :meth:`Selection.polyfit <pyramids.netcdf.engines.selection.Selection.polyfit>`."""
    return self.selection.polyfit(dim, deg)

curvefit(dim, func, p0, *, bounds=None, full=False) #

Facade — :meth:Selection.curvefit <pyramids.netcdf.engines.selection.Selection.curvefit>.

Source code in src/pyramids/netcdf/netcdf.py
def curvefit(
    self,
    dim: str,
    func: Callable[..., Any],
    p0: Sequence[float],
    *,
    bounds: tuple[Any, Any] | None = None,
    full: bool = False,
) -> NetCDF:
    """Facade — :meth:`Selection.curvefit <pyramids.netcdf.engines.selection.Selection.curvefit>`."""
    return self.selection.curvefit(dim, func, p0, bounds=bounds, full=full)

rolling_exp(dim, alpha, *, how='mean') #

Facade — :meth:Selection.rolling_exp <pyramids.netcdf.engines.selection.Selection.rolling_exp>.

Source code in src/pyramids/netcdf/netcdf.py
def rolling_exp(self, dim: str, alpha: float, *, how: str = "mean") -> NetCDF:
    """Facade — :meth:`Selection.rolling_exp <pyramids.netcdf.engines.selection.Selection.rolling_exp>`."""
    return self.selection.rolling_exp(dim, alpha, how=how)

cumulative(dim) #

Facade — :meth:Selection.cumulative <pyramids.netcdf.engines.selection.Selection.cumulative>.

Source code in src/pyramids/netcdf/netcdf.py
def cumulative(self, dim: str) -> CumulativeAccessor:
    """Facade — :meth:`Selection.cumulative <pyramids.netcdf.engines.selection.Selection.cumulative>`."""
    return self.selection.cumulative(dim)

cumsum(dim, *, skipna=True) #

Facade — :meth:Selection.cumsum <pyramids.netcdf.engines.selection.Selection.cumsum>.

Source code in src/pyramids/netcdf/netcdf.py
def cumsum(self, dim: str, *, skipna: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.cumsum <pyramids.netcdf.engines.selection.Selection.cumsum>`."""
    return self.selection.cumsum(dim, skipna=skipna)

cumprod(dim, *, skipna=True) #

Facade — :meth:Selection.cumprod <pyramids.netcdf.engines.selection.Selection.cumprod>.

Source code in src/pyramids/netcdf/netcdf.py
def cumprod(self, dim: str, *, skipna: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.cumprod <pyramids.netcdf.engines.selection.Selection.cumprod>`."""
    return self.selection.cumprod(dim, skipna=skipna)

shift(dim, periods=1, *, fill_value=None) #

Facade — :meth:Selection.shift <pyramids.netcdf.engines.selection.Selection.shift>.

Source code in src/pyramids/netcdf/netcdf.py
def shift(self, dim: str, periods: int = 1, *, fill_value: Any = None) -> NetCDF:
    """Facade — :meth:`Selection.shift <pyramids.netcdf.engines.selection.Selection.shift>`."""
    return self.selection.shift(dim, periods, fill_value=fill_value)

ffill(dim, *, limit=None) #

Facade — :meth:Selection.ffill <pyramids.netcdf.engines.selection.Selection.ffill>.

Source code in src/pyramids/netcdf/netcdf.py
def ffill(self, dim: str, *, limit: int | None = None) -> NetCDF:
    """Facade — :meth:`Selection.ffill <pyramids.netcdf.engines.selection.Selection.ffill>`."""
    return self.selection.ffill(dim, limit=limit)

bfill(dim, *, limit=None) #

Facade — :meth:Selection.bfill <pyramids.netcdf.engines.selection.Selection.bfill>.

Source code in src/pyramids/netcdf/netcdf.py
def bfill(self, dim: str, *, limit: int | None = None) -> NetCDF:
    """Facade — :meth:`Selection.bfill <pyramids.netcdf.engines.selection.Selection.bfill>`."""
    return self.selection.bfill(dim, limit=limit)

dropna(dim, *, how='any', thresh=None) #

Facade — :meth:Selection.dropna <pyramids.netcdf.engines.selection.Selection.dropna>.

Source code in src/pyramids/netcdf/netcdf.py
def dropna(
    self, dim: str, *, how: str = "any", thresh: int | None = None
) -> NetCDF:
    """Facade — :meth:`Selection.dropna <pyramids.netcdf.engines.selection.Selection.dropna>`."""
    return self.selection.dropna(dim, how=how, thresh=thresh)

interpolate_na(dim, method='linear', *, limit=None, use_coordinate=True) #

Facade — :meth:Selection.interpolate_na <pyramids.netcdf.engines.selection.Selection.interpolate_na>.

Source code in src/pyramids/netcdf/netcdf.py
def interpolate_na(
    self,
    dim: str,
    method: str = "linear",
    *,
    limit: int | None = None,
    use_coordinate: bool = True,
) -> NetCDF:
    """Facade — :meth:`Selection.interpolate_na <pyramids.netcdf.engines.selection.Selection.interpolate_na>`."""
    return self.selection.interpolate_na(
        dim, method, limit=limit, use_coordinate=use_coordinate
    )

to_dataframe(*, variables=None, dropna=False) #

Facade — :meth:Interop.to_dataframe <pyramids.netcdf.engines.interop.Interop.to_dataframe>.

A cube tabulates by variable/dropna, deliberately unlike the raster Dataset.to_dataframe(*, bands=…) it overrides — hence type: ignore[override], matching read_array and the other intentionally-divergent NetCDF facades.

Source code in src/pyramids/netcdf/netcdf.py
def to_dataframe(  # type: ignore[override]
    self, *, variables: Any = None, dropna: bool = False
):
    """Facade — :meth:`Interop.to_dataframe <pyramids.netcdf.engines.interop.Interop.to_dataframe>`.

    A cube tabulates by variable/`dropna`, deliberately unlike the raster
    `Dataset.to_dataframe(*, bands=…)` it overrides — hence `type: ignore[override]`,
    matching `read_array` and the other intentionally-divergent NetCDF facades.
    """
    return self.interop.to_dataframe(variables=variables, dropna=dropna)

to_dict(*, data=True) #

Facade — :func:cube_to_dict <pyramids.netcdf.dict_io.cube_to_dict>.

Source code in src/pyramids/netcdf/netcdf.py
def to_dict(self, *, data: bool = True) -> dict[str, Any]:
    """Facade — :func:`cube_to_dict <pyramids.netcdf.dict_io.cube_to_dict>`."""
    return _dict_io.cube_to_dict(self, data=data)

from_dict(payload) classmethod #

Facade — :func:cube_from_dict <pyramids.netcdf.dict_io.cube_from_dict>.

A classmethod that does not use cls, exactly as :meth:from_array, :meth:from_bytes and :meth:from_xarray are: each always builds a Container whatever subtype it is invoked on, and from_array's docstring records that choice.

Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def from_dict(cls, payload: Any) -> Container:
    """Facade — :func:`cube_from_dict <pyramids.netcdf.dict_io.cube_from_dict>`.

    A `classmethod` that does not use `cls`, exactly as :meth:`from_array`,
    :meth:`from_bytes` and :meth:`from_xarray` are: each always builds a `Container`
    whatever subtype it is invoked on, and `from_array`'s docstring records that choice.
    """
    return _dict_io.cube_from_dict(payload)

to_dask_dataframe(*, variables=None, dropna=False, chunks='auto') #

Facade — :meth:Interop.to_dask_dataframe <pyramids.netcdf.engines.interop.Interop.to_dask_dataframe>.

The lazy sibling of :meth:to_dataframe: a tidy :class:dask.dataframe.DataFrame (dimension coordinates as columns beside the value columns) read lazily through :meth:read_array. to_dask_dataframe(...).compute().set_index([*dims]) reproduces to_dataframe().

Source code in src/pyramids/netcdf/netcdf.py
def to_dask_dataframe(
    self, *, variables: Any = None, dropna: bool = False, chunks: Any = "auto"
):
    """Facade — :meth:`Interop.to_dask_dataframe <pyramids.netcdf.engines.interop.Interop.to_dask_dataframe>`.

    The lazy sibling of :meth:`to_dataframe`: a tidy :class:`dask.dataframe.DataFrame`
    (dimension coordinates as columns beside the value columns) read lazily through
    :meth:`read_array`. `to_dask_dataframe(...).compute().set_index([*dims])` reproduces
    `to_dataframe()`.
    """
    return self.interop.to_dask_dataframe(
        variables=variables, dropna=dropna, chunks=chunks
    )

chunk(chunks='auto') #

Return a lazy, dask-backed view of this cube (issue #1229).

Every gridded variable is read lazily through :meth:read_array with chunks and wrapped in a :class:~pyramids.netcdf._lazy_cube.LazyNetCDF — the raster-cube analogue of :class:~pyramids.feature.LazyFeatureCollection. The view exposes chunks / chunksizes, materialises back to this eager cube with compute() / load(), and manages the dask graph with persist() / unify_chunks().

Only a file-backed cube can be chunked — the lazy read reopens the store per block, so an in-memory or in-place-rebuilt cube (no on-disk path) is refused by :meth:read_array with a clear error.

Parameters:

Name Type Description Default
chunks Any

Chunking spec forwarded to :meth:read_array; "auto" (default) targets dask's byte budget. Any form read_array accepts works.

'auto'

Returns:

Name Type Description
LazyNetCDF LazyNetCDF

A lazy view over this cube's gridded variables.

Raises:

Type Description
ValueError

This cube has no on-disk store to read lazily (see :meth:read_array).

OptionalPackageDoesNotExist

dask is not installed — install the lazy extra.

Source code in src/pyramids/netcdf/netcdf.py
def chunk(self, chunks: Any = "auto") -> LazyNetCDF:
    """Return a lazy, dask-backed view of this cube (issue #1229).

    Every gridded variable is read lazily through :meth:`read_array` with `chunks` and wrapped
    in a :class:`~pyramids.netcdf._lazy_cube.LazyNetCDF` — the raster-cube analogue of
    :class:`~pyramids.feature.LazyFeatureCollection`. The view exposes `chunks` / `chunksizes`,
    materialises back to this eager cube with `compute()` / `load()`, and manages the dask graph
    with `persist()` / `unify_chunks()`.

    Only a **file-backed** cube can be chunked — the lazy read reopens the store per block, so an
    in-memory or in-place-rebuilt cube (no on-disk path) is refused by :meth:`read_array` with a
    clear error.

    Args:
        chunks: Chunking spec forwarded to :meth:`read_array`; `"auto"` (default) targets dask's
            byte budget. Any form `read_array` accepts works.

    Returns:
        LazyNetCDF: A lazy view over this cube's gridded variables.

    Raises:
        ValueError: This cube has no on-disk store to read lazily (see :meth:`read_array`).
        OptionalPackageDoesNotExist: dask is not installed — install the `lazy` extra.
    """
    if _interop._is_in_memory(self):
        raise ValueError(
            "chunk() needs a file-backed cube: the lazy read reopens the store per block, and "
            "this cube has no on-disk path. Write it with to_file() first, or operate eagerly."
        )
    pinned = self._source_var_name
    if pinned is None:
        names = list(self._spatial_variable_names())
    else:
        names = [pinned]
    arrays: dict[str, Any] = {}
    dim_names: dict[str, tuple[str, ...]] = {}
    for name in names:
        var = self if pinned is not None else self._require_raster_variable(name)
        array = (
            self.read_array(chunks=chunks, squeeze=False)
            if pinned is not None
            else self.read_array(name, chunks=chunks, squeeze=False)
        )
        arrays[name] = array
        dim_names[name] = _lazy_cube_dim_names(var, array.ndim)
    return LazyNetCDF(self, arrays, dim_names)

concat(objs, dim) #

Join cubes end to end along one of their dimensions.

Two halves of a time series becoming the whole. Every cube must be on the same grid, carry the same variables, and agree on every dimension but dim; the joined dimension's coordinates are laid end to end in the order the cubes were given.

See :meth:merge for the other join — several variables on one grid, rather than one set of variables over a longer axis.

Every cube's gaps stay gaps: the result declares the first cube's no-data value and the other cubes' gaps are rewritten to it. The one cell this cannot get right is a later cube holding the first cube's sentinel as a real measurement, which is then read as missing — change one of the sentinels before joining such cubes.

Callable either way: NetCDF.concat([first, second], dim) joins the list, and first.concat([second], dim) joins the receiver ahead of it. The two answer the same cube.

Parameters:

Name Type Description Default
objs Any

The cubes, containers or variables, in the order they are joined. On an instance call the receiver comes first, ahead of these.

required
dim str

The non-spatial dimension to join along.

required

Returns:

Name Type Description
NetCDF NetCDF

One container holding the joined cubes.

Raises:

Type Description
ValueError

objs is empty, the cubes carry different variables, or a variable lacks dim or disagrees on another dimension.

AlignmentError

The cubes are not on the same grid.

Examples:

  • Two halves of a four-step cube, joined back into it:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
>>> first = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1), geo_ref=geo_ref,
...     variable_name="t", dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> second = NetCDF.from_array(
...     np.array([3.0, 4.0]).reshape(2, 1, 1), geo_ref=geo_ref,
...     variable_name="t", dims=ExtraDimensions(name="time", values=[12.0, 18.0]),
... )
>>> whole = NetCDF.concat([first, second], "time")
>>> whole.get_variable("t").read_array().ravel().tolist()
[1.0, 2.0, 3.0, 4.0]
>>> whole.get_variable("t")._band_dim_values_map["time"]
[0.0, 6.0, 12.0, 18.0]
- The same join reached through the first cube, which goes at the front:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
>>> first = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1), geo_ref=geo_ref,
...     variable_name="t", dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> second = NetCDF.from_array(
...     np.array([3.0, 4.0]).reshape(2, 1, 1), geo_ref=geo_ref,
...     variable_name="t", dims=ExtraDimensions(name="time", values=[12.0, 18.0]),
... )
>>> first.concat([second], "time").get_variable("t").read_array().ravel().tolist()
[1.0, 2.0, 3.0, 4.0]
Source code in src/pyramids/netcdf/netcdf.py
@_joins_cubes
def concat(self, objs: Any, dim: str) -> NetCDF:
    """Join cubes end to end along one of their dimensions.

    Two halves of a time series becoming the whole. Every cube must be on the same
    grid, carry the same variables, and agree on every dimension but `dim`; the joined
    dimension's coordinates are laid end to end in the order the cubes were given.

    See :meth:`merge` for the other join — several variables on one grid, rather than
    one set of variables over a longer axis.

    Every cube's gaps stay gaps: the result declares the first cube's no-data value
    and the other cubes' gaps are rewritten to it. The one cell this cannot get right
    is a later cube holding the first cube's sentinel as a real measurement, which is
    then read as missing — change one of the sentinels before joining such cubes.

    Callable either way: `NetCDF.concat([first, second], dim)` joins the list, and
    `first.concat([second], dim)` joins the receiver ahead of it. The two answer the
    same cube.

    Args:
        objs: The cubes, containers or variables, in the order they are joined. On an
            instance call the receiver comes first, ahead of these.
        dim: The non-spatial dimension to join along.

    Returns:
        NetCDF: One container holding the joined cubes.

    Raises:
        ValueError: `objs` is empty, the cubes carry different variables, or a variable
            lacks `dim` or disagrees on another dimension.
        AlignmentError: The cubes are not on the same grid.

    Examples:
        - Two halves of a four-step cube, joined back into it:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
          >>> first = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1), geo_ref=geo_ref,
          ...     variable_name="t", dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> second = NetCDF.from_array(
          ...     np.array([3.0, 4.0]).reshape(2, 1, 1), geo_ref=geo_ref,
          ...     variable_name="t", dims=ExtraDimensions(name="time", values=[12.0, 18.0]),
          ... )
          >>> whole = NetCDF.concat([first, second], "time")
          >>> whole.get_variable("t").read_array().ravel().tolist()
          [1.0, 2.0, 3.0, 4.0]
          >>> whole.get_variable("t")._band_dim_values_map["time"]
          [0.0, 6.0, 12.0, 18.0]

          ```
        - The same join reached through the first cube, which goes at the front:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
          >>> first = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1), geo_ref=geo_ref,
          ...     variable_name="t", dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> second = NetCDF.from_array(
          ...     np.array([3.0, 4.0]).reshape(2, 1, 1), geo_ref=geo_ref,
          ...     variable_name="t", dims=ExtraDimensions(name="time", values=[12.0, 18.0]),
          ... )
          >>> first.concat([second], "time").get_variable("t").read_array().ravel().tolist()
          [1.0, 2.0, 3.0, 4.0]

          ```
    """
    return _concat(_with_receiver(self, objs), dim)

merge(objs, *, compat='no_conflicts') #

Put the variables of several cubes side by side on the grid they share.

Not DatasetCollection.merge, which means a spatial mosaic written to a destination file — several rasters covering neighbouring ground becoming one. This is the other operation: one grid, several variables. See :meth:concat for joining along a dimension instead.

Callable either way: NetCDF.merge([first, second]) merges the list, and first.merge([second]) merges the receiver ahead of it.

No dimension is joined here, so the copies of a variable have to agree about every one of them, coordinates included — two measurements a hundred hours apart are refused rather than fused into one step carrying the first's stamp. Use :meth:concat to put them end to end instead.

Parameters:

Name Type Description Default
objs Any

The cubes, containers or variables, all on the same grid. On an instance call the receiver comes first, ahead of these.

required
compat str

What to do with a variable more than one cube carries. "no_conflicts" (default) is xarray's rule: the copies fill each other's gaps, and only a cell both of them hold a different value in is a conflict. A value borrowed into a band that cannot hold it widens that band rather than being truncated into it. "override" takes the first cube's copy as it stands, gaps and all, without reading any other.

'no_conflicts'

Returns:

Name Type Description
NetCDF NetCDF

One container holding the union of the variables.

Raises:

Type Description
ValueError

objs is empty, compat is unknown, two copies of a variable are stamped differently along a band dimension, or two cubes disagree about a cell they both judged.

AlignmentError

The cubes are not on the same grid.

Examples:

  • Two single-variable cubes becoming one container of both:

>>> import numpy as np
>>> from pyramids.netcdf import GeoReference, NetCDF
>>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
>>> rain = NetCDF.from_array(
...     np.ones((1, 1)), geo_ref=geo_ref, variable_name="rain"
... )
>>> temp = NetCDF.from_array(
...     np.zeros((1, 1)), geo_ref=geo_ref, variable_name="temp"
... )
>>> sorted(NetCDF.merge([rain, temp]).variable_names)
['rain', 'temp']
- The same merge reached through the first cube:

>>> import numpy as np
>>> from pyramids.netcdf import GeoReference, NetCDF
>>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
>>> rain = NetCDF.from_array(
...     np.ones((1, 1)), geo_ref=geo_ref, variable_name="rain"
... )
>>> temp = NetCDF.from_array(
...     np.zeros((1, 1)), geo_ref=geo_ref, variable_name="temp"
... )
>>> sorted(rain.merge([temp]).variable_names)
['rain', 'temp']
Source code in src/pyramids/netcdf/netcdf.py
@_joins_cubes
def merge(self, objs: Any, *, compat: str = "no_conflicts") -> NetCDF:
    """Put the variables of several cubes side by side on the grid they share.

    **Not** `DatasetCollection.merge`, which means a *spatial mosaic written to a
    destination file* — several rasters covering neighbouring ground becoming one. This
    is the other operation: one grid, several variables. See :meth:`concat` for joining
    along a dimension instead.

    Callable either way: `NetCDF.merge([first, second])` merges the list, and
    `first.merge([second])` merges the receiver ahead of it.

    No dimension is joined here, so the copies of a variable have to agree about every
    one of them, coordinates included — two measurements a hundred hours apart are
    refused rather than fused into one step carrying the first's stamp. Use
    :meth:`concat` to put them end to end instead.

    Args:
        objs: The cubes, containers or variables, all on the same grid. On an
            instance call the receiver comes first, ahead of these.
        compat: What to do with a variable more than one cube carries.
            `"no_conflicts"` (default) is xarray's rule: the copies fill each other's
            gaps, and only a cell both of them hold a *different* value in is a
            conflict. A value borrowed into a band that cannot hold it widens that
            band rather than being truncated into it. `"override"` takes the first
            cube's copy as it stands, gaps and all, without reading any other.

    Returns:
        NetCDF: One container holding the union of the variables.

    Raises:
        ValueError: `objs` is empty, `compat` is unknown, two copies of a variable are
            stamped differently along a band dimension, or two cubes disagree about a
            cell they both judged.
        AlignmentError: The cubes are not on the same grid.

    Examples:
        - Two single-variable cubes becoming one container of both:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import GeoReference, NetCDF
          >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
          >>> rain = NetCDF.from_array(
          ...     np.ones((1, 1)), geo_ref=geo_ref, variable_name="rain"
          ... )
          >>> temp = NetCDF.from_array(
          ...     np.zeros((1, 1)), geo_ref=geo_ref, variable_name="temp"
          ... )
          >>> sorted(NetCDF.merge([rain, temp]).variable_names)
          ['rain', 'temp']

          ```
        - The same merge reached through the first cube:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import GeoReference, NetCDF
          >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326)
          >>> rain = NetCDF.from_array(
          ...     np.ones((1, 1)), geo_ref=geo_ref, variable_name="rain"
          ... )
          >>> temp = NetCDF.from_array(
          ...     np.zeros((1, 1)), geo_ref=geo_ref, variable_name="temp"
          ... )
          >>> sorted(rain.merge([temp]).variable_names)
          ['rain', 'temp']

          ```
    """
    return _merge(_with_receiver(self, objs), compat=compat)

weighted(weights, dims=None, *, how='mean', skipna=True, q=None) #

Facade — :meth:Selection.weighted <pyramids.netcdf.engines.selection.Selection.weighted>.

Source code in src/pyramids/netcdf/netcdf.py
def weighted(
    self,
    weights: Any,
    dims: Any = None,
    *,
    how: str = "mean",
    skipna: bool = True,
    q: float | None = None,
) -> NetCDF:
    """Facade — :meth:`Selection.weighted <pyramids.netcdf.engines.selection.Selection.weighted>`."""
    return self.selection.weighted(weights, dims, how=how, skipna=skipna, q=q)

argmin(dim, *, skipna=True) #

Facade — :meth:Selection.argmin <pyramids.netcdf.engines.selection.Selection.argmin>.

Source code in src/pyramids/netcdf/netcdf.py
def argmin(self, dim: str, *, skipna: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.argmin <pyramids.netcdf.engines.selection.Selection.argmin>`."""
    return self.selection.argmin(dim, skipna=skipna)

argmax(dim, *, skipna=True) #

Facade — :meth:Selection.argmax <pyramids.netcdf.engines.selection.Selection.argmax>.

Source code in src/pyramids/netcdf/netcdf.py
def argmax(self, dim: str, *, skipna: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.argmax <pyramids.netcdf.engines.selection.Selection.argmax>`."""
    return self.selection.argmax(dim, skipna=skipna)

idxmin(dim, *, skipna=True) #

Facade — :meth:Selection.idxmin <pyramids.netcdf.engines.selection.Selection.idxmin>.

Source code in src/pyramids/netcdf/netcdf.py
def idxmin(self, dim: str, *, skipna: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.idxmin <pyramids.netcdf.engines.selection.Selection.idxmin>`."""
    return self.selection.idxmin(dim, skipna=skipna)

idxmax(dim, *, skipna=True) #

Facade — :meth:Selection.idxmax <pyramids.netcdf.engines.selection.Selection.idxmax>.

Source code in src/pyramids/netcdf/netcdf.py
def idxmax(self, dim: str, *, skipna: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.idxmax <pyramids.netcdf.engines.selection.Selection.idxmax>`."""
    return self.selection.idxmax(dim, skipna=skipna)

to_crs(to_epsg, method=DEFAULT_RESAMPLING, maintain_alignment=False, path=None) #

Reproject the dataset to a different CRS.

On a root MDIM container this reprojects every variable and returns a new container. On a variable subset it delegates to Dataset.to_crs() and wraps the result as NetCDF to preserve variable metadata.

Parameters:

Name Type Description Default
to_epsg int

Target EPSG code (e.g., 4326, 32637).

required
method str

Resampling method. Defaults to "nearest neighbor".

DEFAULT_RESAMPLING
maintain_alignment bool

If True, keep the same number of rows and columns. Defaults to False.

False
path str | Path | None

Optional output .nc path. When set, the reprojected container is streamed straight to that file one slab at a time (so the whole cube is never resident) and a file-backed NetCDF reading it is returned; None (default) builds the result in memory.

None

Returns:

Name Type Description
NetCDF NetCDF

Reprojected container or variable subset.

Notes

The result sits on a new grid, so its spatial axes are named y / x rather than the source's. A member that leaves the horizontal grid unchanged — coarsen, reduce, rolling, cumsum, diff over a band dimension — keeps the names with it; a spatial coarsen / reduce does change the grid and (correctly) drops them, the same as here. Compared by membership below because dimension_names lists the store's dimensions in its own order, not a promised (row, column) one:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.arange(8.0).reshape(2, 2, 2),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
...     spatial_names=("latitude", "longitude"),
... )
>>> sorted(cube.get_variable("t").coarsen("time", 2).dimension_names)
['latitude', 'longitude', 'time']
>>> sorted(cube.to_crs(3857).dimension_names)
['time', 'x', 'y']

Carrying latitude / longitude onto a reprojected grid would name axes after coordinates they no longer hold, so the rename is deliberate. It is the same for resample and crop.

Source code in src/pyramids/netcdf/netcdf.py
def to_crs(
    self,
    to_epsg: int,
    method: str = DEFAULT_RESAMPLING,
    maintain_alignment: bool = False,
    path: str | Path | None = None,
) -> NetCDF:
    """Reproject the dataset to a different CRS.

    On a **root MDIM container** this reprojects every variable
    and returns a new container. On a **variable subset** it
    delegates to `Dataset.to_crs()` and wraps the result as
    `NetCDF` to preserve variable metadata.

    Args:
        to_epsg: Target EPSG code (e.g., 4326, 32637).
        method: Resampling method. Defaults to `"nearest neighbor"`.
        maintain_alignment: If True, keep the same number of rows
            and columns. Defaults to False.
        path: Optional output `.nc` path. When set, the reprojected container is streamed
            straight to that file one slab at a time (so the whole cube is never resident) and a
            **file-backed** `NetCDF` reading it is returned; `None` (default) builds the result
            in memory.

    Returns:
        NetCDF: Reprojected container or variable subset.

    Notes:
        The result sits on a **new grid**, so its spatial axes are named `y` / `x`
        rather than the source's. A member that leaves the horizontal grid unchanged —
        `coarsen`, `reduce`, `rolling`, `cumsum`, `diff` over a *band* dimension —
        keeps the names with it; a *spatial* `coarsen` / `reduce` does change the grid
        and (correctly) drops them, the same as here. Compared by membership below
        because `dimension_names` lists the store's dimensions in its own order, not a
        promised `(row, column)` one:

        ```python
        >>> import numpy as np
        >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
        >>> cube = NetCDF.from_array(
        ...     np.arange(8.0).reshape(2, 2, 2),
        ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
        ...     variable_name="t",
        ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
        ...     spatial_names=("latitude", "longitude"),
        ... )
        >>> sorted(cube.get_variable("t").coarsen("time", 2).dimension_names)
        ['latitude', 'longitude', 'time']
        >>> sorted(cube.to_crs(3857).dimension_names)
        ['time', 'x', 'y']

        ```

        Carrying `latitude` / `longitude` onto a reprojected grid would name axes
        after coordinates they no longer hold, so the rename is deliberate. It is the
        same for `resample` and `crop`.
    """
    if self._is_root_container:
        result = self._apply_to_all_variables(
            "to_crs",
            {
                "to_epsg": to_epsg,
                "method": method,
                "maintain_alignment": maintain_alignment,
            },
            path=path,
        )
    else:
        # to_crs warps the backing raster; a multidim view can't be window-read by GDAL >= 3.13,
        # so materialize it first (mirrors resample).
        self._materialize_md_view()
        result = super().to_crs(
            to_epsg=to_epsg,
            method=method,
            maintain_alignment=maintain_alignment,
        )
        result = self._preserve_netcdf_metadata(result)._persist_to(path)
    return cast("NetCDF", result)

warped_view(crs, method=DEFAULT_RESAMPLING, *, cell_size=None, bbox=None) #

Return a lazy, reprojected view of a variable subset.

Delegates to :meth:pyramids.dataset.Dataset.warped_view and re-wraps the VRT-backed result as NetCDF, preserving the variable-subset metadata (band dims, scale/offset, parent reference) so sel() and read_array(unpack=True) keep working on the view.

A root MDIM container cannot be viewed lazily: a warped VRT is a classic single-variable raster, and warping every variable eagerly would contradict the lazy contract. Use :meth:get_variable to pick a variable first, or :meth:to_crs for an eager whole-container warp.

Parameters:

Name Type Description Default
crs int | str | Any

Target CRS in any form :meth:pyproj.CRS.from_user_input accepts (EPSG int, "EPSG:3857", WKT, PROJ4, pyproj CRS).

required
method str

Resampling method used when windows are read. Defaults to "nearest neighbor".

DEFAULT_RESAMPLING
cell_size float | None

Optional output pixel size in target-CRS units (applied to both axes). None keeps the source resolution.

None
bbox tuple[float, float, float, float] | None

Optional (min_x, min_y, max_x, max_y) output extent in the target CRS; None covers the warped source extent.

None

Returns:

Name Type Description
NetCDF NetCDF

A read-only, VRT-backed reprojected view of the variable.

Raises:

Type Description
ValueError

Called on a root MDIM container instead of a variable subset.

See Also

NetCDF.to_crs: The eager reprojection (handles whole containers).

Source code in src/pyramids/netcdf/netcdf.py
def warped_view(
    self,
    crs: int | str | Any,
    method: str = DEFAULT_RESAMPLING,
    *,
    cell_size: float | None = None,
    bbox: tuple[float, float, float, float] | None = None,
) -> NetCDF:
    """Return a lazy, reprojected view of a **variable subset**.

    Delegates to :meth:`pyramids.dataset.Dataset.warped_view` and re-wraps
    the VRT-backed result as `NetCDF`, preserving the variable-subset
    metadata (band dims, scale/offset, parent reference) so `sel()` and
    `read_array(unpack=True)` keep working on the view.

    A **root MDIM container** cannot be viewed lazily: a warped VRT is a
    classic single-variable raster, and warping every variable eagerly
    would contradict the lazy contract. Use :meth:`get_variable` to pick a
    variable first, or :meth:`to_crs` for an eager whole-container warp.

    Args:
        crs: Target CRS in any form :meth:`pyproj.CRS.from_user_input`
            accepts (EPSG int, ``"EPSG:3857"``, WKT, PROJ4, pyproj CRS).
        method: Resampling method used when windows are read. Defaults to
            ``"nearest neighbor"``.
        cell_size: Optional output pixel size in target-CRS units (applied
            to both axes). ``None`` keeps the source resolution.
        bbox: Optional ``(min_x, min_y, max_x, max_y)`` output extent in
            the **target** CRS; ``None`` covers the warped source extent.

    Returns:
        NetCDF: A read-only, VRT-backed reprojected view of the variable.

    Raises:
        ValueError: Called on a root MDIM container instead of a variable
            subset.

    See Also:
        NetCDF.to_crs: The eager reprojection (handles whole containers).
    """
    if self._is_root_container:
        raise ValueError(
            "warped_view works on a single variable, not a root NetCDF "
            "container — call get_variable(<name>) first and warp that, "
            "or use to_crs() for an eager whole-container reprojection."
        )
    # warped_view builds a VRT over self.raster and warps it with windowed reads. A bottom-up
    # variable's raster is a reversed AsClassicDataset view, which cannot service those reads
    # (arrayStartIdx), and a geostationary view carries a raw scan-angle geotransform. Materialize
    # first so the warp sees a plain MEM raster with the corrected geotransform.
    self._materialize_md_view()
    pinned = super().warped_view(crs, method, cell_size=cell_size, bbox=bbox)
    result = self._preserve_netcdf_metadata(pinned)
    # `Spatial.warped_view` builds the view through `self._ds.__class__`, so
    # `pinned` is already a NetCDF and `_preserve_netcdf_metadata` returns it
    # unchanged -- the pin it set is already on `result`. Re-pinning only if
    # that ever stops holding keeps the guarantee (the VRT must not outlive
    # the handle it reads through) without a self-assignment today.
    if result is not pinned:
        result._warp_source = pinned._warp_source
    return result

resample(cell_size, method=DEFAULT_RESAMPLING, path=None) #

Resample the dataset to a different cell size.

On a root MDIM container this resamples every variable and returns a new container. On a variable subset it delegates to Dataset.resample() and wraps the result as NetCDF to preserve variable metadata.

Parameters:

Name Type Description Default
cell_size float

New cell size.

required
method str

Resampling method. Defaults to "nearest neighbor".

DEFAULT_RESAMPLING
path str | Path | None

Optional output .nc path. When set, the resampled container is streamed straight to that file one slab at a time (so the whole cube is never resident) and a file-backed NetCDF reading it is returned; None (default) builds the result in memory.

None

Returns:

Name Type Description
NetCDF NetCDF

Resampled container or variable subset.

Notes

The cells change size, so the result sits on a new grid and its spatial axes come back named y / x rather than the source's: a cube on latitude / longitude reports ['time', 'x', 'y'] after a resample.

See Also
  • :meth:to_crs: Why the rename happens, and which members keep the source's names instead.
Source code in src/pyramids/netcdf/netcdf.py
def resample(
    self,
    cell_size: float,
    method: str = DEFAULT_RESAMPLING,
    path: str | Path | None = None,
) -> NetCDF:
    """Resample the dataset to a different cell size.

    On a **root MDIM container** this resamples every variable
    and returns a new container. On a **variable subset** it
    delegates to `Dataset.resample()` and wraps the result as
    `NetCDF` to preserve variable metadata.

    Args:
        cell_size: New cell size.
        method: Resampling method. Defaults to `"nearest neighbor"`.
        path: Optional output `.nc` path. When set, the resampled container is streamed straight
            to that file one slab at a time (so the whole cube is never resident) and a
            **file-backed** `NetCDF` reading it is returned; `None` (default) builds the result
            in memory.

    Returns:
        NetCDF: Resampled container or variable subset.

    Notes:
        The cells change size, so the result sits on a **new grid** and its spatial
        axes come back named `y` / `x` rather than the source's: a cube on
        `latitude` / `longitude` reports `['time', 'x', 'y']` after a resample.

    See Also:
        - :meth:`to_crs`: Why the rename happens, and which members keep the source's
          names instead.
    """
    if self._is_root_container:
        result = self._apply_to_all_variables(
            "resample",
            {"cell_size": cell_size, "method": method},
            path=path,
        )
    else:
        # resample warps the backing raster; a multidim view can't be window-read by GDAL >= 3.13.
        self._materialize_md_view()
        result = super().resample(
            cell_size=cell_size,
            method=method,
        )
        result = self._preserve_netcdf_metadata(result)._persist_to(path)
    return cast("NetCDF", result)

isel(*, drop=False, **indexers) #

Facade — :meth:Selection.isel <pyramids.netcdf.engines.selection.Selection.isel>.

Parameters:

Name Type Description Default
drop bool

When True, drop the axes a scalar (point) selector collapsed — a length-one list or slice, and a pre-existing length-one dim, are kept, as in xarray; see the engine method.

False
**indexers Any

dimension=selector pairs; see the engine method.

{}

Returns:

Name Type Description
NetCDF NetCDF

The variable holding the selected bands.

Source code in src/pyramids/netcdf/netcdf.py
def isel(self, *, drop: bool = False, **indexers: Any) -> NetCDF:
    """Facade — :meth:`Selection.isel <pyramids.netcdf.engines.selection.Selection.isel>`.

    Args:
        drop: When `True`, drop the axes a scalar (point) selector collapsed — a
            length-one `list` or `slice`, and a pre-existing length-one dim, are kept,
            as in xarray; see the engine method.
        **indexers: `dimension=selector` pairs; see the engine method.

    Returns:
        NetCDF: The variable holding the selected bands.
    """
    return self.selection.isel(drop=drop, **indexers)

sel(*, method=None, tolerance=None, **kwargs) #

Facade — :meth:Selection.sel <pyramids.netcdf.engines.selection.Selection.sel>.

Parameters:

Name Type Description Default
method str | None

None for an exact match, "nearest" to snap.

None
tolerance float | None

The furthest a "nearest" snap may travel.

None
**kwargs Any

dimension=selector pairs; see the engine method.

{}

Returns:

Name Type Description
NetCDF NetCDF

The variable holding the selected bands.

Source code in src/pyramids/netcdf/netcdf.py
def sel(
    self,
    *,
    method: str | None = None,
    tolerance: float | None = None,
    **kwargs: Any,
) -> NetCDF:
    """Facade — :meth:`Selection.sel <pyramids.netcdf.engines.selection.Selection.sel>`.

    Args:
        method: `None` for an exact match, `"nearest"` to snap.
        tolerance: The furthest a `"nearest"` snap may travel.
        **kwargs: `dimension=selector` pairs; see the engine method.

    Returns:
        NetCDF: The variable holding the selected bands.
    """
    return self.selection.sel(method=method, tolerance=tolerance, **kwargs)

head(indexers=None, **indexers_kwargs) #

Facade — :meth:Selection.head <pyramids.netcdf.engines.selection.Selection.head>.

Source code in src/pyramids/netcdf/netcdf.py
def head(self, indexers: Any = None, **indexers_kwargs: int) -> NetCDF:
    """Facade — :meth:`Selection.head <pyramids.netcdf.engines.selection.Selection.head>`."""
    return self.selection.head(indexers, **indexers_kwargs)

tail(indexers=None, **indexers_kwargs) #

Facade — :meth:Selection.tail <pyramids.netcdf.engines.selection.Selection.tail>.

Source code in src/pyramids/netcdf/netcdf.py
def tail(self, indexers: Any = None, **indexers_kwargs: int) -> NetCDF:
    """Facade — :meth:`Selection.tail <pyramids.netcdf.engines.selection.Selection.tail>`."""
    return self.selection.tail(indexers, **indexers_kwargs)

thin(indexers=None, **indexers_kwargs) #

Facade — :meth:Selection.thin <pyramids.netcdf.engines.selection.Selection.thin>.

Source code in src/pyramids/netcdf/netcdf.py
def thin(self, indexers: Any = None, **indexers_kwargs: int) -> NetCDF:
    """Facade — :meth:`Selection.thin <pyramids.netcdf.engines.selection.Selection.thin>`."""
    return self.selection.thin(indexers, **indexers_kwargs)

drop_isel(**indexers) #

Facade — :meth:Selection.drop_isel <pyramids.netcdf.engines.selection.Selection.drop_isel>.

Source code in src/pyramids/netcdf/netcdf.py
def drop_isel(self, **indexers: Any) -> NetCDF:
    """Facade — :meth:`Selection.drop_isel <pyramids.netcdf.engines.selection.Selection.drop_isel>`."""
    return self.selection.drop_isel(**indexers)

drop_sel(*, errors='raise', **labels) #

Facade — :meth:Selection.drop_sel <pyramids.netcdf.engines.selection.Selection.drop_sel>.

Source code in src/pyramids/netcdf/netcdf.py
def drop_sel(self, *, errors: str = "raise", **labels: Any) -> NetCDF:
    """Facade — :meth:`Selection.drop_sel <pyramids.netcdf.engines.selection.Selection.drop_sel>`."""
    return self.selection.drop_sel(errors=errors, **labels)

sortby(dim, *, ascending=True) #

Facade — :meth:Selection.sortby <pyramids.netcdf.engines.selection.Selection.sortby>.

Source code in src/pyramids/netcdf/netcdf.py
def sortby(self, dim: str, *, ascending: bool = True) -> NetCDF:
    """Facade — :meth:`Selection.sortby <pyramids.netcdf.engines.selection.Selection.sortby>`."""
    return self.selection.sortby(dim, ascending=ascending)

drop_duplicates(dim, *, keep='first') #

Facade — :meth:Selection.drop_duplicates <pyramids.netcdf.engines.selection.Selection.drop_duplicates>.

Source code in src/pyramids/netcdf/netcdf.py
def drop_duplicates(self, dim: str, *, keep: Any = "first") -> NetCDF:
    """Facade — :meth:`Selection.drop_duplicates
    <pyramids.netcdf.engines.selection.Selection.drop_duplicates>`.
    """
    return self.selection.drop_duplicates(dim, keep=keep)

squeeze(dim=None) #

Facade — :meth:Selection.squeeze <pyramids.netcdf.engines.selection.Selection.squeeze>.

Source code in src/pyramids/netcdf/netcdf.py
def squeeze(self, dim: str | None = None) -> NetCDF:
    """Facade — :meth:`Selection.squeeze <pyramids.netcdf.engines.selection.Selection.squeeze>`."""
    return self.selection.squeeze(dim)

expand_dims(dim, value=None) #

Facade — :meth:Selection.expand_dims <pyramids.netcdf.engines.selection.Selection.expand_dims>.

Source code in src/pyramids/netcdf/netcdf.py
def expand_dims(self, dim: str, value: Any = None) -> NetCDF:
    """Facade — :meth:`Selection.expand_dims <pyramids.netcdf.engines.selection.Selection.expand_dims>`."""
    return self.selection.expand_dims(dim, value)

set_coords(names) #

Facade — :meth:Selection.set_coords <pyramids.netcdf.engines.selection.Selection.set_coords>.

Source code in src/pyramids/netcdf/netcdf.py
def set_coords(self, names: str | Sequence[str]) -> NetCDF:
    """Facade — :meth:`Selection.set_coords <pyramids.netcdf.engines.selection.Selection.set_coords>`."""
    return self.selection.set_coords(names)

reset_coords(names=None) #

Facade — :meth:Selection.reset_coords <pyramids.netcdf.engines.selection.Selection.reset_coords>.

Source code in src/pyramids/netcdf/netcdf.py
def reset_coords(self, names: str | Sequence[str] | None = None) -> NetCDF:
    """Facade — :meth:`Selection.reset_coords
    <pyramids.netcdf.engines.selection.Selection.reset_coords>`.
    """
    return self.selection.reset_coords(names)

broadcast_like(other) #

Facade — :meth:Selection.broadcast_like <pyramids.netcdf.engines.selection.Selection.broadcast_like>.

Source code in src/pyramids/netcdf/netcdf.py
def broadcast_like(self, other: Any) -> NetCDF:
    """Facade — :meth:`Selection.broadcast_like <pyramids.netcdf.engines.selection.Selection.broadcast_like>`."""
    return self.selection.broadcast_like(other)

broadcast_equals(other) #

Facade — :meth:Selection.broadcast_equals <pyramids.netcdf.engines.selection.Selection.broadcast_equals>.

Source code in src/pyramids/netcdf/netcdf.py
def broadcast_equals(self, other: Any) -> bool:
    """Facade — :meth:`Selection.broadcast_equals
    <pyramids.netcdf.engines.selection.Selection.broadcast_equals>`.
    """
    return self.selection.broadcast_equals(other)

rename_dims(dims=None, **dims_kwargs) #

Facade — :meth:Selection.rename_dims <pyramids.netcdf.engines.selection.Selection.rename_dims>.

Source code in src/pyramids/netcdf/netcdf.py
def rename_dims(
    self, dims: Mapping[str, str] | None = None, **dims_kwargs: str
) -> NetCDF:
    """Facade — :meth:`Selection.rename_dims <pyramids.netcdf.engines.selection.Selection.rename_dims>`."""
    return self.selection.rename_dims(dims, **dims_kwargs)

assign_coords(coords=None, **coords_kwargs) #

Facade — :meth:Selection.assign_coords <pyramids.netcdf.engines.selection.Selection.assign_coords>.

Source code in src/pyramids/netcdf/netcdf.py
def assign_coords(
    self, coords: Mapping[str, Any] | None = None, **coords_kwargs: Any
) -> NetCDF:
    """Facade — :meth:`Selection.assign_coords <pyramids.netcdf.engines.selection.Selection.assign_coords>`."""
    return self.selection.assign_coords(coords, **coords_kwargs)

drop_dims(drop_dims, *, errors='raise') #

Facade — :meth:Selection.drop_dims <pyramids.netcdf.engines.selection.Selection.drop_dims>.

Source code in src/pyramids/netcdf/netcdf.py
def drop_dims(
    self, drop_dims: str | Sequence[str], *, errors: str = "raise"
) -> NetCDF:
    """Facade — :meth:`Selection.drop_dims <pyramids.netcdf.engines.selection.Selection.drop_dims>`."""
    return self.selection.drop_dims(drop_dims, errors=errors)

update(other) #

Facade — :meth:Selection.update <pyramids.netcdf.engines.selection.Selection.update>.

Source code in src/pyramids/netcdf/netcdf.py
def update(self, other: Any) -> None:  # type: ignore[override]
    """Facade — :meth:`Selection.update <pyramids.netcdf.engines.selection.Selection.update>`."""
    self.selection.update(other)

read_file(path, read_only=True, open_as_multi_dimensional=True, file_i=0, *, vsi=None, open_options=None) classmethod #

Open a NetCDF file from a path, URL, or archive member.

Plain local paths, /vsi* paths, and URL schemes (http(s)://, s3://, gs://, az://, abfs:// / abfss://, file://) are all accepted — URLs are transparently rewritten to GDAL's virtual filesystem. Compressed archives (.zip / .tar / .tar.gz / .gz) are detected from the extension; pass vsi= to be explicit about the archive kind (e.g. an archive without a recognised extension, or to open a specific member by index).

Parameters:

Name Type Description Default
path str | Path

Path or URL of the .nc file or archive.

required
read_only bool

If True, open in read-only mode. Set to False for write access. Defaults to True.

True
open_as_multi_dimensional bool

If True, open with gdal.OF_MULTIDIM_RASTER to access the full group / dimension / variable hierarchy. If False, open in classic raster mode where each variable is a subdataset. Defaults to True.

True
file_i int

Which member to open when path is (or is forced to be) a multi-file archive. Default 0.

0
vsi str | None

Treat path as an archive of this kind and open member file_i from inside it: "zip", "tar" (also "tar.gz" / "tgz"), "gzip" (also "gz"), or "auto" (infer from the extension). Default None — path is opened directly / extension-sniffed as before. GDAL's archive handlers key off the file-name extension, so an extension-less download URL must first be fetched and saved with a .zip name (or written to /vsimem/<name>.zip via :func:osgeo.gdal.FileFromMemBuffer).

None
open_options dict[str, str] | list[str] | tuple[str, ...] | None

GDAL open options as a mapping ({"HONOUR_VALID_RANGE": "NO"}) or GDAL's native ["KEY=VALUE"] list, forwarded to the netCDF driver and captured on the returned container so the reopen paths reapply them. Default None — no options (#1025).

Platform caveat for NetCDF: GDAL's netCDF driver requires Linux userfaultfd to open a .nc from any /vsi* path (archive, /vsicurl/, /vsimem/ via this route). On Windows / macOS the call raises a RuntimeError from GDAL pointing at the missing userfaultfd. Use :meth:from_bytes to read a downloaded .nc from memory on those platforms.

None

Returns:

Name Type Description
NetCDF NetCDF

The opened dataset.

Note

Reopening a file whose earlier lazy read still has a GDAL handle parked in the process-global pyramids.base._file_manager.FILE_CACHE would leave two live GDAL handles to one NetCDF, which can crash GDAL on Windows. read_file releases any such parked handle for path first and warns (#1224); a lazy array still referencing the file re-opens transparently on its next chunk read. Drop the lazy array(s) or close() the NetCDF before reopening to avoid the warning. See :meth:read_array's Note for the full open-handle lifetime.

Examples:

  • Open a plain .nc from disk and list its variables:
    >>> from pyramids.netcdf import NetCDF
    >>> nc = NetCDF.read_file(
    ...     "tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc"
    ... )
    >>> sorted(nc.variables)
    ['Band1', 'Band2', 'Band3', 'Band4']
    
  • Open a NetCDF held inside a zip — vsi="auto" infers the archive kind from the .zip extension. GDAL's netCDF driver needs Linux userfaultfd to read through /vsizip/, so the open actually succeeds only on Linux; the try / except keeps the doctest runnable on Windows / macOS too (where it falls through with the RuntimeError GDAL raises):
    >>> import tempfile, zipfile
    >>> from pathlib import Path
    >>> from pyramids.netcdf import NetCDF
    >>> src = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc")
    >>> with tempfile.TemporaryDirectory() as tmp:
    ...     zpath = Path(tmp) / "noah.zip"
    ...     with zipfile.ZipFile(zpath, "w") as zf:
    ...         zf.write(src, arcname="noah.nc")
    ...     try:
    ...         nc = NetCDF.read_file(zpath, vsi="auto")
    ...         variables = sorted(nc.variables)
    ...     except RuntimeError:
    ...         variables = ["Band1", "Band2", "Band3", "Band4"]
    >>> variables
    ['Band1', 'Band2', 'Band3', 'Band4']
    
See Also
  • :meth:from_bytes: open a NetCDF from in-memory bytes.
  • :meth:pyramids.dataset.Dataset.read_file: the same vsi= / file_i= surface for GeoTIFFs.
Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def read_file(  # type: ignore[override]
    cls,
    path: str | Path,
    read_only: bool = True,
    open_as_multi_dimensional: bool = True,
    file_i: int = 0,
    *,
    vsi: str | None = None,
    open_options: dict[str, str] | list[str] | tuple[str, ...] | None = None,
) -> NetCDF:
    """Open a NetCDF file from a path, URL, or archive member.

    Plain local paths, ``/vsi*`` paths, and URL schemes
    (``http(s)://``, ``s3://``, ``gs://``, ``az://``, ``abfs://`` / ``abfss://``,
    ``file://``) are all accepted — URLs are transparently rewritten
    to GDAL's virtual filesystem. Compressed archives (``.zip`` /
    ``.tar`` / ``.tar.gz`` / ``.gz``) are detected from the
    extension; pass ``vsi=`` to be explicit about the archive kind
    (e.g. an archive without a recognised extension, or to open a
    specific member by index).

    Args:
        path: Path or URL of the ``.nc`` file or archive.
        read_only: If True, open in read-only mode. Set to False for
            write access. Defaults to True.
        open_as_multi_dimensional: If True, open with
            ``gdal.OF_MULTIDIM_RASTER`` to access the full group /
            dimension / variable hierarchy. If False, open in
            classic raster mode where each variable is a subdataset.
            Defaults to True.
        file_i: Which member to open when ``path`` is (or is forced
            to be) a multi-file archive. Default ``0``.
        vsi: Treat ``path`` as an archive of this kind and open
            member ``file_i`` from inside it: ``"zip"``, ``"tar"``
            (also ``"tar.gz"`` / ``"tgz"``), ``"gzip"`` (also
            ``"gz"``), or ``"auto"`` (infer from the extension).
            Default ``None`` — ``path`` is opened directly /
            extension-sniffed as before. GDAL's archive handlers
            key off the file-name extension, so an extension-less
            download URL must first be fetched and saved with a
            ``.zip`` name (or written to ``/vsimem/<name>.zip`` via
            :func:`osgeo.gdal.FileFromMemBuffer`).
        open_options: GDAL open options as a mapping
            (``{"HONOUR_VALID_RANGE": "NO"}``) or GDAL's native
            ``["KEY=VALUE"]`` list, forwarded to the netCDF driver and
            captured on the returned container so the reopen paths reapply
            them. Default ``None`` — no options (#1025).

            **Platform caveat for NetCDF:** GDAL's netCDF driver
            requires Linux ``userfaultfd`` to open a ``.nc`` from
            any ``/vsi*`` path (archive, ``/vsicurl/``, ``/vsimem/``
            via this route). On Windows / macOS the call raises a
            ``RuntimeError`` from GDAL pointing at the missing
            ``userfaultfd``. Use :meth:`from_bytes` to read a
            downloaded ``.nc`` from memory on those platforms.

    Returns:
        NetCDF: The opened dataset.

    Note:
        Reopening a file whose earlier lazy read still has a GDAL handle parked in the
        process-global `pyramids.base._file_manager.FILE_CACHE` would leave two live GDAL
        handles to one NetCDF, which can crash GDAL on Windows. `read_file` releases any such
        parked handle for `path` first and warns (#1224); a lazy array still referencing the
        file re-opens transparently on its next chunk read. Drop the lazy array(s) or
        `close()` the `NetCDF` before reopening to avoid the warning. See :meth:`read_array`'s
        Note for the full open-handle lifetime.

    Examples:
        - Open a plain ``.nc`` from disk and list its variables:
            ```python
            >>> from pyramids.netcdf import NetCDF
            >>> nc = NetCDF.read_file(
            ...     "tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc"
            ... )
            >>> sorted(nc.variables)
            ['Band1', 'Band2', 'Band3', 'Band4']

            ```
        - Open a NetCDF held inside a zip — ``vsi="auto"`` infers
          the archive kind from the ``.zip`` extension. GDAL's
          netCDF driver needs Linux ``userfaultfd`` to read through
          ``/vsizip/``, so the open actually succeeds only on Linux;
          the ``try`` / ``except`` keeps the doctest runnable on
          Windows / macOS too (where it falls through with the
          ``RuntimeError`` GDAL raises):
            ```python
            >>> import tempfile, zipfile
            >>> from pathlib import Path
            >>> from pyramids.netcdf import NetCDF
            >>> src = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc")
            >>> with tempfile.TemporaryDirectory() as tmp:
            ...     zpath = Path(tmp) / "noah.zip"
            ...     with zipfile.ZipFile(zpath, "w") as zf:
            ...         zf.write(src, arcname="noah.nc")
            ...     try:
            ...         nc = NetCDF.read_file(zpath, vsi="auto")
            ...         variables = sorted(nc.variables)
            ...     except RuntimeError:
            ...         variables = ["Band1", "Band2", "Band3", "Band4"]
            >>> variables
            ['Band1', 'Band2', 'Band3', 'Band4']

            ```

    See Also:
        - :meth:`from_bytes`: open a NetCDF from in-memory bytes.
        - :meth:`pyramids.dataset.Dataset.read_file`: the same
          ``vsi=`` / ``file_i=`` surface for GeoTIFFs.
    """
    # Guard against two live GDAL handles to one file (#1224): a prior lazy read may have parked
    # a handle for this path in the process-global FILE_CACHE, and opening a second handle while
    # it is parked can crash GDAL on Windows. Release the parked handle(s) first -- a lazy array
    # still referencing this file re-opens transparently on its next chunk read -- and warn so
    # the caller knows to drop that array before continued concurrent use.
    released = discard_path_handles(path)
    if released:
        warnings.warn(
            f"Reopening {os.fspath(path)!r} while {released} lazy-read handle(s) for it were "
            "still parked; the parked handle(s) were released to avoid two live GDAL handles to "
            "one file (which can crash GDAL on Windows). A lazy array still referencing this file "
            "will re-open on its next chunk read -- drop it or call close() before reopening to "
            "avoid re-parking a second handle.",
            stacklevel=2,
        )
    # Normalize once here so the captured form on the Container is the
    # KEY=VALUE list; _io.read_file re-normalizes idempotently (see the note
    # in Dataset.read_file).
    options = _io.normalize_open_options(open_options)
    src = _io.read_file(
        path,
        read_only,
        open_as_multi_dimensional,
        file_i=file_i,
        vsi=vsi,
        open_options=options,
    )
    access = "read_only" if read_only else "write"
    return Container(
        src,
        access=access,
        open_as_multi_dimensional=open_as_multi_dimensional,
        open_options=options,
    )

from_bytes(data, *, suffix='.nc', name=None, read_only=True, open_as_multi_dimensional=True) classmethod #

Open a NetCDF held in memory as a byte string.

Writes data to a temporary GDAL /vsimem/ path and opens it as a NetCDF — no on-disk temp file needed. Useful for HTTP response bodies, object-store payloads, and test fixtures.

This is not a URL helper — see :meth:pyramids.dataset.Dataset.from_bytes for the rationale. The /vsimem/ entry is removed automatically when the returned :class:NetCDF is garbage-collected.

Parameters:

Name Type Description Default
data bytes | bytearray | memoryview

Raw bytes of a NetCDF file.

required
suffix str

Extension hint for GDAL's driver detection. Defaults to ".nc".

'.nc'
name str | None

Optional label recorded as :attr:file_name (cosmetic). Defaults to None.

None
read_only bool

Open read-only. Defaults to True.

True
open_as_multi_dimensional bool

Open with gdal.OF_MULTIDIM_RASTER to access the full group / dimension / variable hierarchy. Defaults to True.

True

Returns:

Name Type Description
NetCDF NetCDF

The opened in-memory dataset.

Raises:

Type Description
TypeError

data is not a bytes-like object.

ValueError

GDAL could not open the bytes as a NetCDF.

Examples:

  • Open the bytes of a NetCDF and list its variables (the bytes here come from a file, but could be requests.get(url).content):
    >>> from pathlib import Path
    >>> from pyramids.netcdf import NetCDF
    >>> data = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc").read_bytes()
    >>> nc = NetCDF.from_bytes(data, name="downloaded.nc")
    >>> list(nc.variables)
    ['Band1', 'Band2', 'Band3', 'Band4']
    >>> nc.epsg
    4326
    >>> nc.file_name
    'downloaded.nc'
    
  • An in-memory NetCDF cannot be pickled — anchor it to disk first:
    >>> import pickle
    >>> from pathlib import Path
    >>> from pyramids.netcdf import NetCDF
    >>> data = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc").read_bytes()
    >>> try:
    ...     pickle.dumps(NetCDF.from_bytes(data))
    ... except TypeError as exc:
    ...     print("to_file" in str(exc))
    True
    
See Also
  • :meth:read_file: open a NetCDF from a path or URL.
  • :meth:pyramids.dataset.Dataset.from_bytes: the GeoTIFF variant.
Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def from_bytes(  # type: ignore[override]
    cls,
    data: bytes | bytearray | memoryview,
    *,
    suffix: str = ".nc",
    name: str | None = None,
    read_only: bool = True,
    open_as_multi_dimensional: bool = True,
) -> NetCDF:
    """Open a NetCDF held in memory as a byte string.

    Writes ``data`` to a temporary GDAL ``/vsimem/`` path and opens
    it as a NetCDF — no on-disk temp file needed. Useful for HTTP
    response bodies, object-store payloads, and test fixtures.

    This is **not** a URL helper — see
    :meth:`pyramids.dataset.Dataset.from_bytes` for the rationale.
    The ``/vsimem/`` entry is removed automatically when the
    returned :class:`NetCDF` is garbage-collected.

    Args:
        data: Raw bytes of a NetCDF file.
        suffix: Extension hint for GDAL's driver detection. Defaults
            to ``".nc"``.
        name: Optional label recorded as :attr:`file_name`
            (cosmetic). Defaults to ``None``.
        read_only: Open read-only. Defaults to ``True``.
        open_as_multi_dimensional: Open with
            ``gdal.OF_MULTIDIM_RASTER`` to access the full group /
            dimension / variable hierarchy. Defaults to ``True``.

    Returns:
        NetCDF: The opened in-memory dataset.

    Raises:
        TypeError: ``data`` is not a bytes-like object.
        ValueError: GDAL could not open the bytes as a NetCDF.

    Examples:
        - Open the bytes of a NetCDF and list its variables (the bytes
          here come from a file, but could be ``requests.get(url).content``):
            ```python
            >>> from pathlib import Path
            >>> from pyramids.netcdf import NetCDF
            >>> data = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc").read_bytes()
            >>> nc = NetCDF.from_bytes(data, name="downloaded.nc")
            >>> list(nc.variables)
            ['Band1', 'Band2', 'Band3', 'Band4']
            >>> nc.epsg
            4326
            >>> nc.file_name
            'downloaded.nc'

            ```
        - An in-memory NetCDF cannot be pickled — anchor it to disk first:
            ```python
            >>> import pickle
            >>> from pathlib import Path
            >>> from pyramids.netcdf import NetCDF
            >>> data = Path("tests/data/netcdf/cf__6v__1d2-2d4__geog__y-asc.nc").read_bytes()
            >>> try:
            ...     pickle.dumps(NetCDF.from_bytes(data))
            ... except TypeError as exc:
            ...     print("to_file" in str(exc))
            True

            ```

    See Also:
        - :meth:`read_file`: open a NetCDF from a path or URL.
        - :meth:`pyramids.dataset.Dataset.from_bytes`: the GeoTIFF variant.
    """
    src, vsi_path = _io.bytes_to_gdal(
        data,
        suffix=suffix,
        read_only=read_only,
        open_as_multi_dimensional=open_as_multi_dimensional,
    )
    try:
        obj = Container(
            src,
            access="read_only" if read_only else "write",
            open_as_multi_dimensional=open_as_multi_dimensional,
        )
    except Exception as e:
        src = None
        _io.silent_unlink(vsi_path)
        raise ValueError(
            "could not open the supplied bytes as a NetCDF dataset "
            f"(the data may be corrupt or truncated): {e}"
        ) from e
    obj._vsimem_path = vsi_path
    weakref.finalize(obj, _io.silent_unlink, vsi_path)
    if name is not None:
        obj._file_name = str(name)
    return obj

to_kerchunk(output_path, *, inline_threshold=500, vlen_encode='embed') #

Emit a kerchunk JSON reference manifest for this file.

Thin forwarder to :func:pyramids.netcdf._kerchunk_facade.to_kerchunk using self._file_name as the source path. Requires the [lazy] optional extra.

Parameters:

Name Type Description Default
output_path

Path where the manifest JSON is written.

required
inline_threshold int

Chunks smaller than this many bytes are embedded directly. Default 500.

500
vlen_encode str

VLEN string handling mode. Default "embed".

'embed'

Returns:

Name Type Description
dict dict

The manifest dict that was written.

Source code in src/pyramids/netcdf/netcdf.py
def to_kerchunk(
    self,
    output_path,
    *,
    inline_threshold: int = 500,
    vlen_encode: str = "embed",
) -> dict:
    """Emit a kerchunk JSON reference manifest for this file.

    Thin forwarder to :func:`pyramids.netcdf._kerchunk_facade.to_kerchunk`
    using `self._file_name` as the source path. Requires the
    `[lazy]` optional extra.

    Args:
        output_path: Path where the manifest JSON is written.
        inline_threshold: Chunks smaller than this many bytes are
            embedded directly. Default 500.
        vlen_encode: VLEN string handling mode. Default `"embed"`.

    Returns:
        dict: The manifest dict that was written.
    """
    return to_kerchunk(
        self._file_name,
        output_path,
        inline_threshold=inline_threshold,
        vlen_encode=vlen_encode,
    )

combine_kerchunk(paths, output_path, *, concat_dims=('time',), identical_dims=('lat', 'lon'), inline_threshold=500) classmethod #

Emit a combined kerchunk manifest spanning many NetCDFs.

Thin forwarder to :func:pyramids.netcdf._kerchunk_facade.combine_kerchunk. Requires the [lazy] optional extra.

Parameters:

Name Type Description Default
paths

Sequence of NetCDF paths to combine.

required
output_path

Path where the combined manifest is written.

required
concat_dims

Dimension name(s) along which to concatenate. Default ("time",).

('time',)
identical_dims

Dimensions expected to match across all files. Default ("lat", "lon").

('lat', 'lon')
inline_threshold int

Chunks smaller than this inline bytes are embedded. Default 500.

500

Returns:

Name Type Description
dict dict

The combined manifest.

Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def combine_kerchunk(
    cls,
    paths,
    output_path,
    *,
    concat_dims=("time",),
    identical_dims=("lat", "lon"),
    inline_threshold: int = 500,
) -> dict:
    """Emit a combined kerchunk manifest spanning many NetCDFs.

    Thin forwarder to
    :func:`pyramids.netcdf._kerchunk_facade.combine_kerchunk`. Requires
    the `[lazy]` optional extra.

    Args:
        paths: Sequence of NetCDF paths to combine.
        output_path: Path where the combined manifest is written.
        concat_dims: Dimension name(s) along which to concatenate.
            Default `("time",)`.
        identical_dims: Dimensions expected to match across all
            files. Default `("lat", "lon")`.
        inline_threshold: Chunks smaller than this inline bytes are
            embedded. Default 500.

    Returns:
        dict: The combined manifest.
    """
    return combine_kerchunk(
        paths,
        output_path,
        concat_dims=concat_dims,
        identical_dims=identical_dims,
        inline_threshold=inline_threshold,
    )

open_mfdataset(paths, variable, *, chunks=None, parallel=False, preprocess=None) classmethod #

Open many NetCDFs and stack variable into one lazy dask array.

Thin forwarder to :func:pyramids.netcdf._mfdataset.open_mfdataset; see that function for the full argument contract. Requires the [lazy] optional extra.

Parameters:

Name Type Description Default
paths

Glob string, explicit path, or sequence of paths.

required
variable str

Name of the variable to extract from each file.

required
chunks

Chunk spec forwarded to :meth:NetCDF.read_array.

None
parallel bool

Deprecated and inert; kept for backward compatibility. Passing a truthy value emits a DeprecationWarning.

False
preprocess

Optional callable applied to each :class:NetCDF before extraction.

None

Returns:

Type Description

dask.array.Array: Stack of shape (n_files, *var_shape).

Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def open_mfdataset(
    cls,
    paths,
    variable: str,
    *,
    chunks=None,
    parallel: bool = False,
    preprocess=None,
):
    """Open many NetCDFs and stack `variable` into one lazy dask array.

    Thin forwarder to
    :func:`pyramids.netcdf._mfdataset.open_mfdataset`; see that
    function for the full argument contract. Requires the
    `[lazy]` optional extra.

    Args:
        paths: Glob string, explicit path, or sequence of paths.
        variable: Name of the variable to extract from each file.
        chunks: Chunk spec forwarded to
            :meth:`NetCDF.read_array`.
        parallel: Deprecated and inert; kept for backward compatibility.
            Passing a truthy value emits a `DeprecationWarning`.
        preprocess: Optional callable applied to each
            :class:`NetCDF` before extraction.

    Returns:
        dask.array.Array: Stack of shape `(n_files, *var_shape)`.
    """
    return open_mfdataset(
        paths,
        variable,
        chunks=chunks,
        parallel=parallel,
        preprocess=preprocess,
    )

get_all_metadata(open_options=None) #

Get full MDIM metadata (uncached).

Unlike meta_data (which is cached), this always re-traverses the GDAL multidimensional structure.

Parameters:

Name Type Description Default
open_options dict | None

Driver-specific open options forwarded to get_metadata(). Defaults to None.

None

Returns:

Type Description
NetCDFMetadata

NetCDFMetadata

Source code in src/pyramids/netcdf/netcdf.py
def get_all_metadata(self, open_options: dict | None = None) -> NetCDFMetadata:
    """Get full MDIM metadata (uncached).

    Unlike `meta_data` (which is cached), this always re-traverses
    the GDAL multidimensional structure.

    Args:
        open_options: Driver-specific open options forwarded to
            `get_metadata()`. Defaults to None.

    Returns:
        NetCDFMetadata
    """
    result = get_metadata(
        self._raster, open_options, start_group=self._working_group()
    )
    return result

get_time_variable(var_name='time', time_format='%Y-%m-%d') #

Parse the time coordinate variable into formatted date strings.

Reads the units attribute (e.g., "days since 1979-01-01") from the dimension metadata and converts raw numeric values to human-readable date strings.

Parameters:

Name Type Description Default
var_name str

Name of the time dimension / variable. Defaults to "time".

'time'
time_format str

strftime format for the output strings. Defaults to "%Y-%m-%d".

'%Y-%m-%d'

Returns:

Type Description
list[str] | None

list[str] or None: Formatted time strings, or None if the

list[str] | None

time dimension is not found, lacks a units attribute, or

list[str] | None

carries a units that is not a CF time origin (e.g. asking

list[str] | None

for a spatial dimension, whose units are degrees_north).

Source code in src/pyramids/netcdf/netcdf.py
def get_time_variable(
    self, var_name: str = "time", time_format: str = "%Y-%m-%d"
) -> list[str] | None:
    """Parse the time coordinate variable into formatted date strings.

    Reads the `units` attribute (e.g., `"days since 1979-01-01"`)
    from the dimension metadata and converts raw numeric values to
    human-readable date strings.

    Args:
        var_name: Name of the time dimension / variable.
            Defaults to `"time"`.
        time_format: strftime format for the output strings.
            Defaults to `"%Y-%m-%d"`.

    Returns:
        list[str] or None: Formatted time strings, or None if the
        time dimension is not found, lacks a `units` attribute, or
        carries a `units` that is not a CF time origin (e.g. asking
        for a spatial dimension, whose units are `degrees_north`).
    """
    time_stamp = None
    time_dim = self.meta_data.get_dimension(var_name)
    if time_dim is not None:
        units = time_dim.attrs.get("units")
        if units is not None:
            calendar = time_dim.attrs.get("calendar", "standard")
            time_vals = self._read_variable(var_name)
            if time_vals is not None:
                try:
                    func = create_time_conversion_func(
                        units, time_format, calendar=calendar
                    )
                except Exception:
                    # Guard only the parse, exactly as `_decode_time_labels` does. Every
                    # dimension now carries its `units` (#1078), so a non-temporal one no
                    # longer "lacks" the attribute and reaches this parse with something
                    # like "degrees_north". Returning None keeps the documented contract
                    # rather than turning a wrong-dimension query into an exception.
                    return None
                # The value conversion stays outside the guard, so a genuinely malformed
                # coordinate value still surfaces instead of being hidden.
                time_stamp = list(map(func, time_vals.reshape(-1)))
    return time_stamp

get_time_values(var_name='time') #

Raw (undecoded) values of the time coordinate, or None if absent.

Use this when :meth:get_time_variable returns None because the store's CF units are not parseable — some cloud Zarr stores do not surface units through GDAL, so dates can't be decoded. The raw offsets, together with the dimension's calendar / units attributes (meta_data.get_dimension(var_name).attrs) and :attr:dimension_sizes, let a caller map a date window to integer indices for :meth:subset and detect out-of-range — instead of hard-coding an unverifiable schedule.

Parameters:

Name Type Description Default
var_name str

Name of the time coordinate / dimension. Defaults to "time".

'time'

Returns:

Type Description
NDArray | None

numpy.ndarray or None: The raw coordinate values, or None when

NDArray | None

the store has no such dimension. On a variable subset the values are

NDArray | None

that view's own — a time-subsetted cube reports the steps it kept, not

NDArray | None

the source file's whole axis.

Examples:

  • Raw 3-hourly offsets of the NWM retrospective cube (needs the bucket)::

    nc.get_time_values("time")[:3] # doctest: +SKIP array([0, 3, 6])

See Also

get_dimension_values: the same read for any dimension — this method is the time-axis spelling of it.

Source code in src/pyramids/netcdf/netcdf.py
def get_time_values(self, var_name: str = "time") -> np.typing.NDArray | None:
    """Raw (undecoded) values of the time coordinate, or ``None`` if absent.

    Use this when :meth:`get_time_variable` returns ``None`` because the
    store's CF ``units`` are not parseable — some cloud Zarr stores do not
    surface ``units`` through GDAL, so dates can't be decoded. The raw
    offsets, together with the dimension's ``calendar`` / ``units`` attributes
    (``meta_data.get_dimension(var_name).attrs``) and :attr:`dimension_sizes`,
    let a caller map a date window to integer indices for :meth:`subset` and
    detect out-of-range — instead of hard-coding an unverifiable schedule.

    Args:
        var_name: Name of the time coordinate / dimension. Defaults to
            ``"time"``.

    Returns:
        numpy.ndarray or None: The raw coordinate values, or ``None`` when
        the store has no such dimension. On a variable subset the values are
        that view's own — a time-subsetted cube reports the steps it kept, not
        the source file's whole axis.

    Examples:
        - Raw 3-hourly offsets of the NWM retrospective cube (needs the
          bucket)::

            >>> nc.get_time_values("time")[:3]  # doctest: +SKIP
            array([0, 3, 6])

    See Also:
        `get_dimension_values`: the same read for any dimension — this method is
            the time-axis spelling of it.
    """
    return self.get_dimension_values(var_name)

get_dimension_values(name) #

Stored coordinate values of any dimension, or None if it has none.

The general form of :meth:get_time_values — the accessor for a non-spatial, non-time axis (level, depth, member, …), whose values were previously reachable only through the optional to_xarray bridge or by provoking sel's "Available values" error.

Answers from whichever source the receiver has:

  • On a variable subset (what :meth:get_variable returns) a band dimension is answered from the subset's own band-dim coordinates, so after a :meth:sel the values reflect what that view actually holds — which is how a sel(..., method="nearest") caller reads back the coordinate it snapped to.
  • On a root container the coordinate variable is read from the store, for any dimension it declares. A subset asked for a dimension it does not track — its spatial axes — has no coordinate variable to read and answers None; those come from the geotransform (:meth:~pyramids.dataset.transform.GeoTransform.x_axis).

Values are the stored ones, matching what :meth:sel matches against and what to_xarray(decode_times=False).coords reports for the same file. A CF time axis is therefore raw offsets; :meth:get_time_variable decodes the same axis to date strings, and :meth:to_xarray decodes it to datetime64[ns] by default.

Storage order is also the array order, which for a spatial axis need not be the raster's. Pyramids presents rasters north-up, but a south-to-north file stores its latitudes ascending, so get_dimension_values("lat") comes back [40, 41, …, 44] while :meth:read_array's row 0 is the lat-44 row. Do not index raster rows with this — :meth:~pyramids.dataset.transform.GeoTransform.y_axis builds the row centres from the geotransform, in raster order. The values here answer "what does this file store", which is the question :meth:sel and the CF metadata ask.

Parameters:

Name Type Description Default
name str

Dimension name, as listed by :attr:dimension_names / :attr:dimension_sizes (e.g. "level").

required

Returns:

Name Type Description
NDArray | None

numpy.ndarray or None: The coordinate values in storage order, or None

NDArray | None

when the dataset has no such dimension or it carries no coordinate variable.

NDArray | None

One caveat on a variable subset: a band dimension whose indexing variable

NDArray | None

cannot be read at all is tracked as the placeholder [0, 1, …, size - 1]

NDArray | None

and comes back here as those integers rather than as None. They are also

what NDArray | None

meth:sel matches against on such an axis, so the two agree; they are

NDArray | None

just not the file's own labels.

Examples:

  • Read the pressure levels of a 4-D cube, then the levels one sel kept::

    nc.get_dimension_values("level") # doctest: +SKIP array([1000., 925., 850., 700.]) nc.get_variable("rhum").sel(level=850).get_dimension_values("level") # doctest: +SKIP array([850.])

See Also

get_time_variable: decodes a CF time axis to date strings. sel: selects bands by these values.

Source code in src/pyramids/netcdf/netcdf.py
def get_dimension_values(self, name: str) -> np.typing.NDArray | None:
    """Stored coordinate values of any dimension, or ``None`` if it has none.

    The general form of :meth:`get_time_values` — the accessor for a *non-spatial,
    non-time* axis (``level``, ``depth``, ``member``, …), whose values were
    previously reachable only through the optional `to_xarray` bridge or by
    provoking `sel`'s "Available values" error.

    Answers from whichever source the receiver has:

    * On a **variable subset** (what :meth:`get_variable` returns) a band dimension
      is answered from the subset's own band-dim coordinates, so after a
      :meth:`sel` the values reflect what that view actually holds — which is how a
      `sel(..., method="nearest")` caller reads back the coordinate it snapped to.
    * On a **root container** the coordinate variable is read from the store, for
      any dimension it declares. A subset asked for a dimension it does not track —
      its spatial axes — has no coordinate variable to read and answers ``None``;
      those come from the geotransform (:meth:`~pyramids.dataset.transform.GeoTransform.x_axis`).

    Values are the **stored** ones, matching what :meth:`sel` matches against and what
    ``to_xarray(decode_times=False).coords`` reports for the same file. A CF time axis
    is therefore raw offsets; :meth:`get_time_variable` decodes the same axis to date
    strings, and :meth:`to_xarray` decodes it to ``datetime64[ns]`` by default.

    Storage order is also the *array* order, which for a **spatial** axis need not be
    the raster's. Pyramids presents rasters north-up, but a south-to-north file stores
    its latitudes ascending, so ``get_dimension_values("lat")`` comes back
    ``[40, 41, …, 44]`` while :meth:`read_array`'s row 0 is the lat-44 row. Do not
    index raster rows with this — :meth:`~pyramids.dataset.transform.GeoTransform.y_axis`
    builds the row centres from the geotransform, in raster order. The values here answer
    "what does this file store", which is the question :meth:`sel` and the CF metadata ask.

    Args:
        name: Dimension name, as listed by :attr:`dimension_names` /
            :attr:`dimension_sizes` (e.g. ``"level"``).

    Returns:
        numpy.ndarray or None: The coordinate values in storage order, or ``None``
        when the dataset has no such dimension or it carries no coordinate variable.
        One caveat on a variable subset: a band dimension whose indexing variable
        cannot be read at all is tracked as the placeholder ``[0, 1, …, size - 1]``
        and comes back here as those integers rather than as ``None``. They are also
        what :meth:`sel` matches against on such an axis, so the two agree; they are
        just not the file's own labels.

    Examples:
        - Read the pressure levels of a 4-D cube, then the levels one `sel` kept::

            >>> nc.get_dimension_values("level")  # doctest: +SKIP
            array([1000.,  925.,  850.,  700.])
            >>> nc.get_variable("rhum").sel(level=850).get_dimension_values("level")  # doctest: +SKIP
            array([850.])

    See Also:
        `get_time_variable`: decodes a CF time axis to date strings.
        `sel`: selects bands by these values.
    """
    tracked = self._band_dim_values_map.get(name)
    names = self.dimension_names
    values: np.typing.NDArray | None
    if tracked is not None:
        values = np.asarray(tracked)
    elif names is not None and name in names:
        values = self._read_dimension_coordinates(name)
    else:
        values = None
    return values

get_group(group_name) #

Open a sub-group as a NetCDF container, without copying its data.

The returned :class:Container is a zero-copy view (ARC-12): it shares this container's open GDAL dataset and records the path to the sub-group, rather than materialising every array into a new in-memory store. Variables, dimensions, and attributes are read from the sub-group in place, and each variable's data is materialised only when it is extracted via get_variable — so opening a group of large variables no longer reads them all into memory up front.

(The lazy read_array(chunks=) dask path resolves a variable from the store's root group, so it does not yet reach a sub-group variable — the same pre-existing limitation as the group-qualified get_variable("group/var") path; eager reads work as usual.)

The view holds its own reference to the shared dataset and keeps this parent container alive, so closing either side only drops a reference; the underlying GDAL dataset is freed once both are released.

Thread-safety: because the view shares the parent's gdal.Dataset (GDAL datasets are not thread-safe), do not read the parent and a view — or two views of the same store — concurrently from different threads. The read_array(threadsafe=True) per-thread-handle path does not cover this shared multidimensional handle; for concurrent access, open the file independently per thread instead.

Parameters:

Name Type Description Default
group_name str

Name of the sub-group. Supports nested paths separated by / (e.g. "forecast/surface"). Applied relative to this container's current group, so get_group can be chained.

required

Returns:

Name Type Description
NetCDF NetCDF

A Container view backed by the sub-group.

Raises:

Type Description
ValueError

If the group doesn't exist or the dataset has no root group.

Source code in src/pyramids/netcdf/netcdf.py
def get_group(self, group_name: str) -> NetCDF:
    """Open a sub-group as a NetCDF container, without copying its data.

    The returned :class:`Container` is a **zero-copy view** (ARC-12): it
    shares this container's open GDAL dataset and records the path to the
    sub-group, rather than materialising every array into a new in-memory
    store. Variables, dimensions, and attributes are read from the
    sub-group in place, and each variable's data is materialised only when
    it is extracted via `get_variable` — so opening a group of large
    variables no longer reads them all into memory up front.

    (The lazy `read_array(chunks=)` dask path resolves a variable from the
    store's root group, so it does not yet reach a sub-group variable — the
    same pre-existing limitation as the group-qualified
    `get_variable("group/var")` path; eager reads work as usual.)

    The view holds its own reference to the shared dataset and keeps this
    parent container alive, so closing either side only drops a reference;
    the underlying GDAL dataset is freed once both are released.

    Thread-safety: because the view shares the parent's `gdal.Dataset`
    (GDAL datasets are not thread-safe), do **not** read the parent and a
    view — or two views of the same store — concurrently from different
    threads. The `read_array(threadsafe=True)` per-thread-handle path does
    not cover this shared multidimensional handle; for concurrent access,
    open the file independently per thread instead.

    Args:
        group_name: Name of the sub-group. Supports nested paths
            separated by `/` (e.g. `"forecast/surface"`). Applied
            relative to this container's current group, so `get_group`
            can be chained.

    Returns:
        NetCDF: A `Container` view backed by the sub-group.

    Raises:
        ValueError: If the group doesn't exist or the dataset
            has no root group.
    """
    rg = self._working_group()
    if rg is None:
        raise ValueError("get_group requires a multidimensional container.")

    # Validate that the requested (possibly nested) path resolves from the
    # current working group before building the view.
    group = rg
    for part in group_name.split("/"):
        try:
            group = group.OpenGroup(part)
        except RuntimeError:
            group = None
        if group is None:
            raise ValueError(
                f"Group '{group_name}' not found. "
                f"Available groups: {self.group_names}"
            )

    # Zero-copy view: share the parent's open dataset and record the path to
    # the sub-group. `_working_group()` resolves it on demand, so no array
    # data is copied. Compose paths so get_group() chains on an existing view.
    view = Container(self._raster, access=self._access)
    view._group_path = (
        group_name if not self._group_path else f"{self._group_path}/{group_name}"
    )
    # Pin the parent so the shared dataset (and its SWIG wrappers) outlive the
    # view even if the caller drops the parent reference.
    view._parent_nc = self
    # A group view's pickle recipe reopens the parent file, so it inherits the
    # parent's captured GDAL open options (#1025).
    view._open_options = self._open_options
    return view

get_variable_names() #

Deprecated alias for the :attr:variable_names property (API-3).

Returns:

Type Description
list[str]

list[str]: Same value as :attr:variable_names.

Source code in src/pyramids/netcdf/netcdf.py
def get_variable_names(self) -> list[str]:
    """Deprecated alias for the :attr:`variable_names` property (API-3).

    Returns:
        list[str]: Same value as :attr:`variable_names`.
    """
    warnings.warn(
        "get_variable_names() is deprecated; use the `variable_names` property "
        "instead.",
        DeprecationWarning,
        stacklevel=2,
    )
    return self.variable_names

get_variable(variable_name, x_dim=None, y_dim=None) #

Extract a single variable as a classic-raster NetCDF object.

A variable GDAL cannot expose as a raster plane answers as a LabeledArray instead of a NetCDF; everything below describes the raster case, and Returns sets out the other one.

The returned object carries origin metadata so modified data can be written back via set_variable(). Every non-spatial dim of the variable is tracked: for an N-D MDIM array (d_0, ..., d_{n-1}, lat, lon) the build path populates _band_dim_names, _band_dim_values_map, and _band_dim_sizes with all non-spatial dims in storage order, while the legacy _band_dim_name / _band_dim_values keep pointing at the first non-spatial dim so existing 3-D consumers see no change. 4-D+ files (e.g. CDS-Beta ERA5 pressure-levels with (valid_time, pressure_level, lat, lon)) are addressable via sel() along any tracked band dim.

Supports group-qualified names: "forecast/temperature" first navigates to the forecast sub-group, then extracts temperature from it.

Parameters:

Name Type Description Default
variable_name str

Name of the variable to extract. Use / to separate group path from variable name.

required
x_dim str | None

Dimension to map to the raster X axis (columns). When omitted, the longitude dimension is auto-detected from the coordinate variables' CF attributes (axis, standard_name, units), falling back to the last dimension. Use this for files whose lon/lat are not the trailing dims and lack CF axis metadata.

None
y_dim str | None

Dimension to map to the raster Y axis (rows). When omitted, the latitude dimension is auto-detected, else the second-to-last dimension is used.

Both are ignored for a variable that has no raster plane to map them onto — a 1-D or non-numeric one (see Returns).

None

Returns:

Type Description
NetCDF | LabeledArray

NetCDF | LabeledArray: A subset backed by a classic dataset where every non-spatial dimension is mapped onto bands. The new _band_dim_names / _band_dim_values_map / _band_dim_sizes fields drive sel(); the legacy _band_dim_name / _band_dim_values track the first non-spatial dim.

A variable GDAL cannot expose as a raster comes back as a LabeledArray — values alongside the dims and shape GDAL declares, plus the name, unit, no_data_value, attributes, scale and offset the array carries — rather than as a NetCDF: a 1-D variable of any dtype, and any non-numeric variable of any rank, a character/string column (however the driver exposes it) or a compound (struct) type. It is not a raster, so none of the band-dim tracking above applies to it and neither do sel() or the raster methods; read the values off .values (#1126). They are the stored values: no CF time decoding, and no scale_factor / add_offset applied unless you ask read_array for unpack=True -- the wrapper's scale / offset say what unpacking needs. Each call returns a fresh, writeable wrapper; the variables mapping hands out a frozen, shared one.

LabeledDataset["var"] hands back the same class from the same store, so the two agree on type — but it decodes a CF time axis, applies that store's current selection, squeezes the scalar-selected dimensions out, and leaves the label fields above unset. Do not read the two as interchangeable.

Raises:

Type Description
ValueError

If variable_name is not present in the dataset, or it names a compound array that cannot be materialised -- one with a string field, which GDAL's Python bindings cannot read.

Notes

String-typed indexing variables (e.g. WRF's Times array) cannot be read via GDAL SWIG bindings; the build path falls back to integer indices [0, 1, ..., size - 1] for those dims.

Examples:

  • A variable with a raster plane comes back as a NetCDF subset:
    >>> import numpy as np
    >>> from osgeo import gdal
    >>> from pyramids.netcdf import Container
    >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
    >>> group = store.GetRootGroup()
    >>> lat = group.CreateDimension("lat", None, None, 2)
    >>> lon = group.CreateDimension("lon", None, None, 3)
    >>> grid = group.CreateMDArray(
    ...     "t2m", [lat, lon], gdal.ExtendedDataType.Create(gdal.GDT_Float32)
    ... )
    >>> grid.Write(np.arange(6, dtype="float32").reshape(2, 3))
    0
    >>> Container(store).get_variable("t2m").shape
    (1, 2, 3)
    
  • A 1-D variable has no raster plane, so it answers as a LabeledArray, carrying the labels GDAL declares for it:
    >>> import numpy as np
    >>> from osgeo import gdal
    >>> from pyramids.netcdf import Container
    >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
    >>> group = store.GetRootGroup()
    >>> ilev = group.CreateDimension("ilev", None, None, 4)
    >>> coefficients = group.CreateMDArray(
    ...     "hyai", [ilev], gdal.ExtendedDataType.Create(gdal.GDT_Float64)
    ... )
    >>> coefficients.Write(np.array([0.0, 0.25, 0.5, 1.0]))
    0
    >>> coefficients.SetUnit("1")
    0
    >>> long_name = coefficients.CreateAttribute(
    ...     "long_name", [], gdal.ExtendedDataType.CreateString()
    ... )
    >>> long_name.Write("hybrid A coefficient")
    0
    >>> hyai = Container(store).get_variable("hyai")
    >>> hyai.dims, hyai.shape
    (('ilev',), (4,))
    >>> hyai.values.tolist()
    [0.0, 0.25, 0.5, 1.0]
    >>> hyai.name, hyai.unit
    ('hyai', '1')
    >>> hyai.attributes["long_name"]
    'hybrid A coefficient'
    
  • A string column is non-numeric at any rank, so it is labelled too — including the 2-D (record, strlen) shape a driver that models the string length as a dimension reports:
    >>> from osgeo import gdal
    >>> from pyramids.netcdf import Container
    >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
    >>> group = store.GetRootGroup()
    >>> record = group.CreateDimension("record", None, None, 2)
    >>> strlen = group.CreateDimension("strlen", None, None, 3)
    >>> units = group.CreateMDArray(
    ...     "units", [record, strlen], gdal.ExtendedDataType.CreateString()
    ... )
    >>> station = group.CreateMDArray(
    ...     "station", [record], gdal.ExtendedDataType.CreateString()
    ... )
    >>> station.Write(["brienz", "thun"])
    0
    >>> nc = Container(store)
    >>> nc.get_variable("units").dims
    ('record', 'strlen')
    >>> nc.get_variable("station")
    LabeledArray('station', dims=('record',), shape=(2,))
    >>> nc.get_variable("station").values.tolist()
    ['brienz', 'thun']
    
See Also

sel: subsets the result along any tracked band dim. pyramids.netcdf.LabeledArray: what a non-raster variable answers with. pyramids.netcdf.LabeledDataset: reads a whole label-indexed store the same way.

Source code in src/pyramids/netcdf/netcdf.py
11293
11294
11295
11296
11297
11298
11299
11300
11301
11302
11303
11304
11305
11306
11307
11308
11309
11310
11311
11312
11313
11314
11315
11316
11317
11318
11319
11320
11321
11322
11323
11324
11325
11326
11327
11328
11329
11330
11331
11332
11333
11334
11335
11336
11337
11338
11339
11340
11341
11342
11343
11344
11345
11346
11347
11348
11349
11350
11351
11352
11353
11354
11355
11356
11357
11358
11359
11360
11361
11362
11363
11364
11365
11366
11367
11368
11369
11370
11371
11372
11373
11374
11375
11376
11377
11378
11379
11380
11381
11382
11383
11384
11385
11386
11387
11388
11389
11390
11391
11392
11393
11394
11395
11396
11397
11398
11399
11400
11401
11402
11403
11404
11405
11406
11407
11408
11409
11410
11411
11412
11413
11414
11415
11416
11417
11418
11419
11420
11421
11422
11423
11424
11425
11426
11427
11428
11429
11430
11431
11432
11433
11434
11435
11436
11437
11438
11439
11440
11441
11442
11443
11444
11445
11446
11447
11448
11449
11450
11451
11452
11453
11454
11455
11456
11457
11458
11459
11460
11461
11462
11463
11464
11465
11466
11467
11468
11469
11470
11471
11472
11473
11474
11475
11476
11477
11478
11479
11480
11481
11482
11483
11484
11485
11486
11487
11488
11489
11490
11491
11492
11493
11494
11495
11496
11497
11498
11499
11500
11501
11502
11503
11504
11505
11506
11507
11508
11509
11510
11511
11512
11513
11514
11515
11516
11517
11518
11519
11520
11521
11522
11523
11524
11525
11526
11527
11528
11529
11530
11531
11532
11533
11534
11535
11536
11537
11538
11539
11540
11541
11542
11543
11544
11545
11546
11547
11548
11549
11550
11551
11552
11553
11554
11555
11556
11557
11558
11559
11560
11561
11562
11563
11564
11565
11566
11567
11568
11569
def get_variable(
    self, variable_name: str, x_dim: str | None = None, y_dim: str | None = None
) -> NetCDF | LabeledArray:
    """Extract a single variable as a classic-raster NetCDF object.

    A variable GDAL cannot expose as a raster plane answers as a
    `LabeledArray` instead of a `NetCDF`; everything below describes the
    raster case, and `Returns` sets out the other one.

    The returned object carries origin metadata so modified data
    can be written back via `set_variable()`. Every non-spatial
    dim of the variable is tracked: for an N-D MDIM array
    `(d_0, ..., d_{n-1}, lat, lon)` the build path populates
    `_band_dim_names`, `_band_dim_values_map`, and
    `_band_dim_sizes` with all non-spatial dims in storage order,
    while the legacy `_band_dim_name` / `_band_dim_values` keep
    pointing at the first non-spatial dim so existing 3-D
    consumers see no change. 4-D+ files (e.g. CDS-Beta ERA5
    pressure-levels with `(valid_time, pressure_level, lat, lon)`)
    are addressable via `sel()` along any tracked band dim.

    Supports group-qualified names: `"forecast/temperature"` first
    navigates to the `forecast` sub-group, then extracts
    `temperature` from it.

    Args:
        variable_name: Name of the variable to extract. Use `/`
            to separate group path from variable name.
        x_dim: Dimension to map to the raster X axis (columns).
            When omitted, the longitude dimension is auto-detected
            from the coordinate variables' CF attributes (`axis`,
            `standard_name`, `units`), falling back to the last
            dimension. Use this for files whose lon/lat are not the
            trailing dims and lack CF axis metadata.
        y_dim: Dimension to map to the raster Y axis (rows). When
            omitted, the latitude dimension is auto-detected, else
            the second-to-last dimension is used.

            Both are ignored for a variable that has no raster plane to
            map them onto — a 1-D or non-numeric one (see `Returns`).

    Returns:
        NetCDF | LabeledArray: A subset backed by a classic dataset where every
            non-spatial dimension is mapped onto bands. The new
            `_band_dim_names` / `_band_dim_values_map` /
            `_band_dim_sizes` fields drive `sel()`; the legacy
            `_band_dim_name` / `_band_dim_values` track the first
            non-spatial dim.

            A variable GDAL cannot expose as a raster comes back as a
            `LabeledArray` — `values` alongside the `dims` and `shape`
            GDAL declares, plus the `name`, `unit`, `no_data_value`,
            `attributes`, `scale` and `offset` the array carries — rather
            than as a `NetCDF`: a 1-D variable of any dtype, and any
            **non-numeric** variable of any rank, a character/string
            column (however the driver exposes it) or a compound (struct)
            type. It is not a raster, so none of the band-dim tracking
            above applies to it and neither do `sel()` or the raster
            methods; read the values off `.values` (#1126). They are the
            stored values: no CF time decoding, and no `scale_factor` /
            `add_offset` applied unless you ask `read_array` for
            `unpack=True` -- the wrapper's `scale` / `offset` say what
            unpacking needs. Each call returns a fresh, writeable wrapper;
            the `variables` mapping hands out a frozen, shared one.

            `LabeledDataset["var"]` hands back the same class from the
            same store, so the two agree on type — but it decodes a CF
            time axis, applies that store's current selection, squeezes
            the scalar-selected dimensions out, and leaves the label
            fields above unset. Do not read the two as interchangeable.

    Raises:
        ValueError: If `variable_name` is not present in the dataset, or it
            names a compound array that cannot be materialised -- one with a
            string field, which GDAL's Python bindings cannot read.

    Notes:
        String-typed indexing variables (e.g. WRF's `Times` array)
        cannot be read via GDAL SWIG bindings; the build path falls
        back to integer indices `[0, 1, ..., size - 1]` for those
        dims.

    Examples:
        - A variable with a raster plane comes back as a `NetCDF` subset:
            ```python
            >>> import numpy as np
            >>> from osgeo import gdal
            >>> from pyramids.netcdf import Container
            >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
            >>> group = store.GetRootGroup()
            >>> lat = group.CreateDimension("lat", None, None, 2)
            >>> lon = group.CreateDimension("lon", None, None, 3)
            >>> grid = group.CreateMDArray(
            ...     "t2m", [lat, lon], gdal.ExtendedDataType.Create(gdal.GDT_Float32)
            ... )
            >>> grid.Write(np.arange(6, dtype="float32").reshape(2, 3))
            0
            >>> Container(store).get_variable("t2m").shape
            (1, 2, 3)

            ```
        - A 1-D variable has no raster plane, so it answers as a
          `LabeledArray`, carrying the labels GDAL declares for it:
            ```python
            >>> import numpy as np
            >>> from osgeo import gdal
            >>> from pyramids.netcdf import Container
            >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
            >>> group = store.GetRootGroup()
            >>> ilev = group.CreateDimension("ilev", None, None, 4)
            >>> coefficients = group.CreateMDArray(
            ...     "hyai", [ilev], gdal.ExtendedDataType.Create(gdal.GDT_Float64)
            ... )
            >>> coefficients.Write(np.array([0.0, 0.25, 0.5, 1.0]))
            0
            >>> coefficients.SetUnit("1")
            0
            >>> long_name = coefficients.CreateAttribute(
            ...     "long_name", [], gdal.ExtendedDataType.CreateString()
            ... )
            >>> long_name.Write("hybrid A coefficient")
            0
            >>> hyai = Container(store).get_variable("hyai")
            >>> hyai.dims, hyai.shape
            (('ilev',), (4,))
            >>> hyai.values.tolist()
            [0.0, 0.25, 0.5, 1.0]
            >>> hyai.name, hyai.unit
            ('hyai', '1')
            >>> hyai.attributes["long_name"]
            'hybrid A coefficient'

            ```
        - A string column is non-numeric at any rank, so it is labelled too
          — including the 2-D `(record, strlen)` shape a driver that models
          the string length as a dimension reports:
            ```python
            >>> from osgeo import gdal
            >>> from pyramids.netcdf import Container
            >>> store = gdal.GetDriverByName("MEM").CreateMultiDimensional("demo")
            >>> group = store.GetRootGroup()
            >>> record = group.CreateDimension("record", None, None, 2)
            >>> strlen = group.CreateDimension("strlen", None, None, 3)
            >>> units = group.CreateMDArray(
            ...     "units", [record, strlen], gdal.ExtendedDataType.CreateString()
            ... )
            >>> station = group.CreateMDArray(
            ...     "station", [record], gdal.ExtendedDataType.CreateString()
            ... )
            >>> station.Write(["brienz", "thun"])
            0
            >>> nc = Container(store)
            >>> nc.get_variable("units").dims
            ('record', 'strlen')
            >>> nc.get_variable("station")
            LabeledArray('station', dims=('record',), shape=(2,))
            >>> nc.get_variable("station").values.tolist()
            ['brienz', 'thun']

            ```

    See Also:
        `sel`: subsets the result along any tracked band dim.
        `pyramids.netcdf.LabeledArray`: what a non-raster variable answers with.
        `pyramids.netcdf.LabeledDataset`: reads a whole label-indexed store the
            same way.
    """
    # Handle group-qualified names: "forecast/temperature"
    if "/" in variable_name:
        return self._get_group_variable(variable_name, x_dim, y_dim)

    # Checked against what the store *holds*, not against `variable_names`:
    # that property enumerates data variables, so CF non-data arrays -- 2-D
    # curvilinear coordinates like `xc` / `lat_rho`, bounds, ancillary
    # fields -- are deliberately absent from it, yet reading one by name is
    # legitimate. Gating on the enumeration made `get_variable("xc")` fail
    # on a curvilinear store while the array sat right there.
    readable = self._readable_variable_names()
    if variable_name not in readable:
        # The list shown is the list the check above consults, not
        # `variable_names`. Printing the narrow enumeration told a user who
        # mistyped `lat_rho` that the store held only `['salt', 'zeta']` --
        # a refusal that names none of the accepted answers.
        raise ValueError(
            f"{variable_name} is not a valid variable name in {readable}"
        )

    prefix = self.driver_type.upper()
    rg = self._working_group()
    md_arr_ref = None
    rg_ref = None

    spatial_dim_indices: tuple[int, int] | None = None
    if prefix == "MEMORY" or rg is not None:
        src, md_arr_ref, rg_ref, x_index, y_index, y_flipped, x_flipped = (
            self._read_md_array(variable_name, x_dim=x_dim, y_dim=y_dim)
        )
        if x_index is not None:
            spatial_dim_indices = (x_index, y_index)
        if isinstance(src, gdal.Dataset):
            # Declared `NetCDF`, not inferred: `_georeference_index_subset`
            # below reassigns `cube` a `NetCDF`, and with the group branch now
            # in its own method this is the first assignment mypy sees.
            cube: NetCDF = Variable(src)
            cube._is_md_array = True
            # Which spatial axes _read_md_array reversed, and where the raster plane sits in the
            # MDArray. The eager materialize path rebuilds the unreversed view from these and
            # re-applies the flips with NumPy (see _materialize_from_raw_view).
            cube._md_y_flipped = y_flipped
            cube._md_x_flipped = x_flipped
            cube._md_spatial_dims = spatial_dim_indices
            # _read_md_array flips the data lazily and GDAL usually corrects the geotransform,
            # but a spatial dim with no indexing variable (e.g. WRF "south_north") can leave it
            # wrong; fix it on the wrapper (no data copy).
            self._correct_flipped_geotransform(cube)
        else:
            # `src` is the MDArray itself -- GDAL could not expose it as a
            # raster plane. Materialise it instead of returning it: a public
            # accessor must never hand back a raw GDAL handle (#1126). The
            # references below are neither needed nor settable for it: the
            # values are materialised into NumPy's own memory rather than a
            # view over GDAL's, so nothing has to be kept alive, and
            # `LabeledArray` defines `__slots__`.
            return _labeled_array_from_md_array(src, variable_name)
        # Keep GDAL SWIG references alive — AsClassicDataset returns a
        # view whose C++ backing is owned by the MDArray/root group.
        # Without these the view becomes a dangling pointer on Windows.
        cube._gdal_md_arr_ref = md_arr_ref
        cube._gdal_rg_ref = rg_ref
    else:
        # Prefer the real /vsimem backing path over a cosmetic from_bytes `name=` that
        # shadows file_name, so a named in-memory classic read reopens the true source (#1057).
        path = getattr(self, "_vsimem_path", None) or self.file_name
        src = gdal.Open(f'{prefix}:"{path}":{variable_name}')
        if src is None:
            raise ValueError(
                f"Could not open variable '{variable_name}' via "
                f'\'{prefix}:"{path}":{variable_name}\''
            )
        cube = Variable(src)
        cube._is_md_array = False

    cube._is_subset = True

    # --- RT-4: Track variable origin for round-trip ---
    cube._parent_nc = self
    cube._source_var_name = variable_name
    # A variable subset's pickle recipe reopens the parent file, so it inherits
    # the parent's captured GDAL open options (#1025).
    cube._open_options = self._open_options

    # Geostationary (GOES) scan-angle x/y come through the MDIM read path in
    # radians; rescale them to projected metres so the cube is correctly
    # georeferenced and to_crs/crop work. No-op for every other CRS. The
    # guard this used to carry is gone with the reason for it: a variable
    # with no raster plane returns before reaching here.
    cube._normalize_geostationary_geotransform()

    self._attach_variable_metadata(
        cube, md_arr_ref if rg is not None else None, spatial_dim_indices
    )
    cube = self._georeference_index_subset(cube)
    # Take the band dimensions' time units now, while this container is open, so the variable,
    # and anything derived from it, still decodes its stamps (a date `sel`, a frequency
    # `reduce`) after the container is closed.
    cube._band_dim_time_attrs = cube._resolved_band_dim_time_attrs()
    # A container rebuilt by an operation knows the grid it produced; a variable of it holds
    # no coordinate arrays, so it would otherwise fall back to the index space of the view.
    if self._stamped_geotransform is not None:
        cube._stamped_geotransform = self._stamped_geotransform
        cube._geotransform = self._stamped_geotransform
        cube._derived_geotransform = self._stamped_geotransform
        cube._cell_size = GeoTransform(*self._stamped_geotransform).cell_size
    # Record the raster built from the store, after every step above that may replace it, so a
    # streamed read is used only while this variable still reads as its store.
    cube._store_raster = cube._raster
    return cube

combine(other, func, *, band=None, no_data_value=_DERIVE_NO_DATA) #

Combine two rasters cell by cell, keeping the operands' band dimensions.

Delegates to Dataset.combine. Its engine, Analysis._combine, checks the band layouts through _combine_layout_source once the grids and band counts are known to agree and before any pixel is read, then labels the result with the band dimensions — names, sizes and coordinates — through _label_combined, so a combined (time, pressure_level) variable can still be passed to sel, isel or anything else that reads that layout. The operators +, -, *, /, <, <=, > and >= between two variables reach this method, so they keep the labels too; == and != stay Python's identity comparison and never get here. A scalar operand (var * 2, 2 - var) reaches the same engine without passing through here, and keeps them as well.

The labels come from this variable when it has them, and from other when only it does. What one operand lacks, the other supplies, so no label is lost to the order the operands are written in: a dimension without coordinates on one side takes the other side's coordinates with their time units, and agreeing coordinates without time units take the ones the other side has. Where both sides have agreeing coordinates, this variable's are kept, in its own units: hours [0, 6] plus days [0, 0.25] since one origin comes back as hours [0, 6], the reverse as days [0, 0.25], the same instants.

When both carry band dimensions, their names and sizes must match, or the planes do not pair up and the call is refused. Their coordinate values may differ: two cuts of one variable at different steps (sel(time=6.0) - sel(time=0.0), a tendency isel(time=slice(1, None)) - isel(time=slice(None, -1))) combine, and a dimension whose coordinates disagree keeps its name and length but comes back without coordinates, since neither operand's stamps describe the result. isel still reaches it; sel by value on that dimension raises ValueError. Where both operands carry CF time units for a dimension and the units differ, the stamps are compared as decoded time instants, to the microsecond — hours [0, 6] and days [0, 0.25] since the same origin are one axis, while milliseconds [0, 400] and seconds [0, 0.9] are not. Only CF time units count: levels in millibar against levels in hPa compare their raw values, as every other case does. A coordinate-less dimension on either side is not compared. The check runs only when band is None and the two grids and band counts agree.

A broadcast pair is the exception to all of the above: when one operand carries a single band it applies to every band of the other, so the layouts are not compared at all. The multi-band operand owns the result's dimensions, names, sizes and coordinates, whichever side it is on, and the one-band operand lends nothing — its single stamp cannot describe the result's planes. cube * mask and mask * cube therefore come back with the same layout.

Two consequences of that rule are worth stating, because both used to be a band-count ValueError and now succeed:

  • A band dimension only the one-band operand has is dropped: cube(time=3) * level_slice(level=1) comes back tracking time alone. The result's three planes are the cube's three steps; the single level plane was applied to each of them and is not an axis of the answer.
  • When the multi-band operand is a plain raster — or a classic-mode container — it has no band dimensions to lend, so the result tracks none, even if the one-band operand had some. mask(time=1) * scene(3 bands) is three bands with no band dimensions, so isel, sel, coords and to_xarray do not apply to it, concat refuses it, and broadcast_like refuses it too. The alternative would be to keep time at length three, which would claim three time steps where the operands held one; an unlabelled result is the honest answer.

Such a result cannot be labelled afterwards — expand_dims only adds a length-one axis and assign_coords needs an existing dimension — so broadcast the operands before combining them if the layout matters. And note what to_file does with it: it succeeds, writing one variable per plane (Band1, Band2, …), so a three-step cube reopens as three unrelated single-plane variables.

Parameters:

Name Type Description Default
other Dataset

The second operand, on this variable's grid.

required
func Callable[[ndarray, ndarray], ndarray]

Binary callable applied to the operands' matching cells.

required
band int | None

Zero-based band to combine, or None for every band. A single band cannot carry a multi-band layout, so passing one skips the layout check and leaves the result without band dimensions.

None
no_data_value Any

Sentinel for the result, as documented on Analysis.combine. Left unset it is derived — NaN for a floating result, none at all for an integer result that masked nothing.

_DERIVE_NO_DATA

Returns:

Name Type Description
Dataset Dataset

The combined raster, on this variable's grid and of this variable's

Dataset

class. When band is None it carries the band dimensions of whichever

Dataset

operand supplied them, with the coordinates and time units that operand lacks

Dataset

taken from the other.

Raises:

Type Description
ValueError

band is None, both operands carry band dimensions, their grids and band counts agree, and their dimension names or sizes differ. Differing coordinate values are not refused. Operands whose band counts differ skip the check — a single band against n broadcasts, and any other pair is refused by Dataset.combine with its own message.

AlignmentError

The operands do not share a grid, raised by Dataset.combine. A grid mismatch is reported as this whether or not the band layouts also disagree, since the layouts are only compared on a shared grid.

Examples:

  • The combined variable can still be selected by coordinate:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> nc = NetCDF.from_array(
...     np.ones((2, 3, 4, 5)),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 4.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2, 3])]),
... )
>>> var = nc.get_variable("t")
>>> total = var.combine(var, np.add)
>>> total._band_dim_names, total._band_dim_sizes
(('time', 'level'), (2, 3))
>>> total.sel(time=6.0).band_count
3
- Two steps of one variable subtract, and time comes back without coordinates:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> var = NetCDF.from_array(
...     np.arange(4.0).reshape(2, 2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2])]),
... ).get_variable("t")
>>> change = var.sel(time=6.0) - var.sel(time=0.0)
>>> change._band_dim_values_map
{'time': None, 'level': [1, 2]}
>>> change.read_array().ravel().tolist()
[2.0, 2.0]
- An operand without time coordinates takes the other operand's, in either order:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> var = NetCDF.from_array(
...     np.arange(4.0).reshape(2, 2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2])]),
... ).get_variable("t")
>>> change = var.sel(time=6.0) - var.sel(time=0.0)
>>> later = var.sel(time=6.0)
>>> change._band_dim_values_map
{'time': None, 'level': [1, 2]}
>>> (change + later)._band_dim_values_map
{'time': [6.0], 'level': [1, 2]}
>>> (later + change)._band_dim_values_map
{'time': [6.0], 'level': [1, 2]}
>>> (change + later).sel(time=6.0).read_array().ravel().tolist()
[4.0, 5.0]
- Operands whose dimension names differ are refused:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> geo = GeoReference(geo=(0.0, 1.0, 0.0, 4.0, 0.0, -1.0), epsg=4326)
>>> by_time = NetCDF.from_array(
...     np.ones((2, 4, 5)), geo_ref=geo, variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... ).get_variable("t")
>>> by_step = NetCDF.from_array(
...     np.ones((2, 4, 5)), geo_ref=geo, variable_name="t",
...     dims=ExtraDimensions(name="step", values=[0.0, 6.0]),
... ).get_variable("t")
>>> by_time.combine(by_step, np.add)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
  ...
ValueError: the operands' band dimensions do not line up
Source code in src/pyramids/netcdf/netcdf.py
def combine(
    self,
    other: Dataset,
    func: Callable[[np.ndarray, np.ndarray], np.ndarray],
    *,
    band: int | None = None,
    no_data_value: Any = _DERIVE_NO_DATA,
) -> Dataset:
    """Combine two rasters cell by cell, keeping the operands' band dimensions.

    Delegates to `Dataset.combine`. Its engine, `Analysis._combine`, checks the band
    layouts through `_combine_layout_source` once the grids and band counts are known to
    agree and before any pixel is read, then labels the result with the band dimensions —
    names, sizes and coordinates — through `_label_combined`, so a combined
    `(time, pressure_level)` variable can still be passed to `sel`, `isel` or anything else
    that reads that layout. The operators `+`, `-`, `*`, `/`, `<`, `<=`, `>` and `>=`
    between two variables reach this method, so they keep the labels too; `==` and `!=`
    stay Python's identity comparison and never get here. A scalar operand (`var * 2`,
    `2 - var`) reaches the same engine without passing through here, and keeps them as well.

    The labels come from this variable when it has them, and from `other` when only it
    does. What one operand lacks, the other supplies, so no label is lost to the order the
    operands are written in: a dimension without coordinates on one side takes the other
    side's coordinates with their time units, and agreeing coordinates without time units
    take the ones the other side has. Where both sides have agreeing coordinates, this
    variable's are kept, in its own units: hours `[0, 6]` plus days `[0, 0.25]` since one
    origin comes back as hours `[0, 6]`, the reverse as days `[0, 0.25]`, the same instants.

    When both carry band dimensions, their names and sizes must match, or the planes do not
    pair up and the call is refused. Their coordinate values may differ: two cuts of one
    variable at different steps (`sel(time=6.0) - sel(time=0.0)`, a tendency
    `isel(time=slice(1, None)) - isel(time=slice(None, -1))`) combine, and a dimension
    whose coordinates disagree keeps its name and length but comes back without
    coordinates, since neither operand's stamps describe the result. `isel` still reaches
    it; `sel` by value on that dimension raises `ValueError`. Where both operands carry CF
    time units for a dimension and the units differ, the stamps are compared as decoded
    time instants, to the microsecond — hours `[0, 6]` and days `[0, 0.25]` since the same
    origin are one axis, while milliseconds `[0, 400]` and seconds `[0, 0.9]` are not. Only CF
    time units count: levels in `millibar` against levels in `hPa` compare their raw values,
    as every other case does. A coordinate-less dimension on either side is not compared.
    The check runs only when `band` is `None` and the two grids and band counts agree.

    A **broadcast** pair is the exception to all of the above: when one operand carries a
    single band it applies to every band of the other, so the layouts are not compared at
    all. The multi-band operand owns the result's dimensions, names, sizes and coordinates,
    whichever side it is on, and the one-band operand lends nothing — its single stamp
    cannot describe the result's planes. `cube * mask` and `mask * cube` therefore come
    back with the same layout.

    Two consequences of that rule are worth stating, because both used to be a
    band-count `ValueError` and now succeed:

    - A band dimension only the **one-band** operand has is **dropped**:
      `cube(time=3) * level_slice(level=1)` comes back tracking `time` alone. The
      result's three planes are the cube's three steps; the single `level` plane was
      applied to each of them and is not an axis of the answer.
    - When the multi-band operand is a plain raster — or a classic-mode container — it
      has no band dimensions to lend, so the result tracks **none**, even if the
      one-band operand had some. `mask(time=1) * scene(3 bands)` is three bands with no
      band dimensions, so `isel`, `sel`, `coords` and `to_xarray` do not apply to it,
      `concat` refuses it, and `broadcast_like` refuses it too. The alternative would be
      to keep `time` at length three, which would claim three time steps where the
      operands held one; an unlabelled result is the honest answer.

      Such a result **cannot be labelled afterwards** — `expand_dims` only adds a
      length-one axis and `assign_coords` needs an existing dimension — so broadcast
      the operands before combining them if the layout matters. And note what `to_file`
      does with it: it succeeds, writing **one variable per plane** (`Band1`, `Band2`,
      …), so a three-step cube reopens as three unrelated single-plane variables.

    Args:
        other: The second operand, on this variable's grid.
        func: Binary callable applied to the operands' matching cells.
        band: Zero-based band to combine, or `None` for every band. A single band
            cannot carry a multi-band layout, so passing one skips the layout check and
            leaves the result without band dimensions.
        no_data_value: Sentinel for the result, as documented on
            `Analysis.combine`. Left unset it is derived — NaN for a floating result,
            none at all for an integer result that masked nothing.

    Returns:
        Dataset: The combined raster, on this variable's grid and of this variable's
        class. When `band` is `None` it carries the band dimensions of whichever
        operand supplied them, with the coordinates and time units that operand lacks
        taken from the other.

    Raises:
        ValueError: `band` is `None`, both operands carry band dimensions, their grids
            and band counts agree, and their dimension names or sizes differ. Differing
            coordinate values are not refused. Operands whose band counts differ skip the
            check — a single band against *n* broadcasts, and any other pair is refused by
            `Dataset.combine` with its own message.
        AlignmentError: The operands do not share a grid, raised by `Dataset.combine`.
            A grid mismatch is reported as this whether or not the band layouts also
            disagree, since the layouts are only compared on a shared grid.

    Examples:
        - The combined variable can still be selected by coordinate:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> nc = NetCDF.from_array(
          ...     np.ones((2, 3, 4, 5)),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 4.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2, 3])]),
          ... )
          >>> var = nc.get_variable("t")
          >>> total = var.combine(var, np.add)
          >>> total._band_dim_names, total._band_dim_sizes
          (('time', 'level'), (2, 3))
          >>> total.sel(time=6.0).band_count
          3

          ```
        - Two steps of one variable subtract, and `time` comes back without coordinates:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> var = NetCDF.from_array(
          ...     np.arange(4.0).reshape(2, 2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2])]),
          ... ).get_variable("t")
          >>> change = var.sel(time=6.0) - var.sel(time=0.0)
          >>> change._band_dim_values_map
          {'time': None, 'level': [1, 2]}
          >>> change.read_array().ravel().tolist()
          [2.0, 2.0]

          ```
        - An operand without `time` coordinates takes the other operand's, in either order:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> var = NetCDF.from_array(
          ...     np.arange(4.0).reshape(2, 2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(dims=[("time", [0.0, 6.0]), ("level", [1, 2])]),
          ... ).get_variable("t")
          >>> change = var.sel(time=6.0) - var.sel(time=0.0)
          >>> later = var.sel(time=6.0)
          >>> change._band_dim_values_map
          {'time': None, 'level': [1, 2]}
          >>> (change + later)._band_dim_values_map
          {'time': [6.0], 'level': [1, 2]}
          >>> (later + change)._band_dim_values_map
          {'time': [6.0], 'level': [1, 2]}
          >>> (change + later).sel(time=6.0).read_array().ravel().tolist()
          [4.0, 5.0]

          ```
        - Operands whose dimension names differ are refused:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> geo = GeoReference(geo=(0.0, 1.0, 0.0, 4.0, 0.0, -1.0), epsg=4326)
          >>> by_time = NetCDF.from_array(
          ...     np.ones((2, 4, 5)), geo_ref=geo, variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... ).get_variable("t")
          >>> by_step = NetCDF.from_array(
          ...     np.ones((2, 4, 5)), geo_ref=geo, variable_name="t",
          ...     dims=ExtraDimensions(name="step", values=[0.0, 6.0]),
          ... ).get_variable("t")
          >>> by_time.combine(by_step, np.add)  # doctest: +IGNORE_EXCEPTION_DETAIL
          Traceback (most recent call last):
            ...
          ValueError: the operands' band dimensions do not line up

          ```
    """
    return super().combine(other, func, band=band, no_data_value=no_data_value)

to_file(path, **kwargs) #

Save the dataset to disk.

For .nc / .nc4 files the full multidimensional structure (groups, dimensions, variables, attributes) is preserved via CreateCopy with the netCDF driver. For other extensions (e.g. .tif), the parent Dataset.to_file is used — but only on variable subsets, not on root MDIM containers.

Parameters:

Name Type Description Default
path str | Path

Destination file path. The extension determines the output driver (.nc -> netCDF, .tif -> GeoTIFF, etc.).

required
**kwargs Any

Forwarded to Dataset.to_file for non-NetCDF extensions (e.g. tile_length, creation_options).

{}

Raises:

Type Description
RuntimeError

If the netCDF CreateCopy call fails.

ValueError

If a root MDIM container is saved to a non-NC extension (use .nc or extract a variable first).

Source code in src/pyramids/netcdf/netcdf.py
def to_file(  # type: ignore[override]
    self,
    path: str | Path,
    **kwargs: Any,
) -> None:
    """Save the dataset to disk.

    For `.nc` / `.nc4` files the full multidimensional structure
    (groups, dimensions, variables, attributes) is preserved via
    `CreateCopy` with the netCDF driver. For other extensions
    (e.g. `.tif`), the parent `Dataset.to_file` is used — but only
    on variable subsets, not on root MDIM containers.

    Args:
        path: Destination file path. The extension determines the
            output driver (`.nc` -> netCDF, `.tif` -> GeoTIFF, etc.).
        **kwargs: Forwarded to `Dataset.to_file` for non-NetCDF
            extensions (e.g. `tile_length`, `creation_options`).

    Raises:
        RuntimeError: If the netCDF `CreateCopy` call fails.
        ValueError: If a root MDIM container is saved to a non-NC
            extension (use `.nc` or extract a variable first).
    """
    path = Path(path)
    extension = path.suffix[1:].lower()
    if extension in ("nc", "nc4"):
        self._write_netcdf(path)
    elif self._is_md_array and not self._is_subset:
        raise ValueError(
            "Cannot save a multidimensional NetCDF container as "
            f"'{extension}'. Use .nc extension or extract a "
            "variable first with .get_variable()."
        )
    else:
        super().to_file(path, **kwargs)

copy(path=None) #

Create a deep, standalone copy of this dataset.

The copy keeps this instance's concrete type (a container copies to a Container, a variable to a Variable) and its CF packing metadata (scale / offset / variable attributes / band-dim layout). It is independent, though: a copied variable is a self-contained classic raster, not a live subset of the original's parent, so is_subset is False and it carries no _parent_nc / _source_var_name. That keeps pickling sound — a copy reconstructs from its own data rather than reaching back into the parent store.

Parameters:

Name Type Description Default
path str | Path | None

Destination file path. If None, the copy is created in memory using the MEM driver. Defaults to None.

None

Returns:

Name Type Description
NetCDF NetCDF

A new, standalone NetCDF object (same concrete subclass as self).

Raises:

Type Description
RuntimeError

If CreateCopy fails.

Source code in src/pyramids/netcdf/netcdf.py
def copy(self, path: str | Path | None = None) -> NetCDF:
    """Create a deep, standalone copy of this dataset.

    The copy keeps this instance's concrete type (a container copies to a
    ``Container``, a variable to a ``Variable``) and its CF packing metadata
    (``scale`` / ``offset`` / variable attributes / band-dim layout). It
    is **independent**, though: a copied variable is a self-contained classic raster, not
    a live subset of the original's parent, so ``is_subset`` is ``False`` and it carries
    no ``_parent_nc`` / ``_source_var_name``. That keeps pickling sound — a copy
    reconstructs from its own data rather than reaching back into the parent store.

    Args:
        path: Destination file path. If None, the copy is created
            in memory using the MEM driver. Defaults to None.

    Returns:
        NetCDF: A new, standalone NetCDF object (same concrete subclass as ``self``).

    Raises:
        RuntimeError: If `CreateCopy` fails.
    """
    if path is None:
        path = ""
        driver = "MEM"
    else:
        driver = "netCDF"

    src = gdal.GetDriverByName(driver).CreateCopy(str(path), self._copy_source)
    if src is None:
        raise RuntimeError(f"Failed to copy NetCDF dataset to '{path}'")
    # Preserve both the concrete type AND the variable-subset / origin identity: a copy
    # of a container is a container; a copy of a variable is a usable variable subset.
    # The fresh CreateCopy would otherwise reset these flags through __init__ (defaulting
    # open_as_multi_dimensional=True, _is_subset=False), so re-open in the source's mode
    # and carry the subset/origin + cached variable metadata over.
    # A copied variable subset is a standalone single-variable *classic* raster (its
    # backing is the materialized AsClassicDataset view), not an MDIM store — so it must
    # open classic. A container copy keeps the container's MDIM/classic mode.
    copy_is_md = self._is_md_array and not self._is_subset
    result = type(self)(src, access="write", open_as_multi_dimensional=copy_is_md)
    # A copy is an INDEPENDENT, materialized dataset: its data no longer lives at
    # parent_file::source_var_name, so it must NOT inherit the subset/origin identity
    # that __reduce__ uses to reconstruct (that would pickle-reconstruct from the PARENT,
    # silently reading the wrong data, or fail on a self-contained copy whose band names
    # differ). It keeps its concrete type and CF packing metadata, but is standalone.
    result._is_subset = False
    result._parent_nc = None
    result._source_var_name = None
    result._rebuilt_in_memory = False
    result._md_array_dims = self._md_array_dims
    result._geostationary_scaled = self._geostationary_scaled
    result._variable_attrs = self._variable_attrs
    result._scale = self._scale
    result._offset = self._offset
    NetCDF._copy_band_dim_metadata(result, self)
    return result

create_main_dimension(group, dim_name, dtype, values) staticmethod #

Create a NetCDF dimension with an indexing variable.

The dimension type is inferred from dim_name: y/lat/latitude -> horizontal Y, x/lon/longitude -> horizontal X, bands/time -> temporal.

The dimension is registered in the group together with a matching MDArray that stores the coordinate values. When dtype is a string ExtendedDataType (gdal.ExtendedDataType.CreateString()) a text coordinate axis — WRF's Time of '2000-01-24_12:00:00' stamps, a scenario name — is written through the GDAL string channel (a Python list of str); a NumPy string array sent straight to Write is force-cast to float64 by the SWIG binding and would raise (#1181).

Parameters:

Name Type Description Default
group Group

Root group (or sub-group) of the multidimensional dataset.

required
dim_name str

Name of the dimension to create.

required
dtype Any

GDAL ExtendedDataType for the indexing variable — a numeric type for a numeric axis, or gdal.ExtendedDataType.CreateString() for a text axis.

required
values ndarray

Coordinate values for the dimension; numeric or text to match dtype.

required

Returns:

Type Description
Dimension

gdal.Dimension: The newly created dimension.

Examples:

  • Create a numeric coordinate axis and read the stored values back:
    >>> import numpy as np
    >>> from osgeo import gdal
    >>> from pyramids.netcdf import NetCDF
    >>> ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("mem")
    >>> group = ds.GetRootGroup()
    >>> f64 = gdal.ExtendedDataType.Create(gdal.GDT_Float64)
    >>> dim = NetCDF.create_main_dimension(group, "time", f64, np.array([0.0, 1.0, 2.0]))
    >>> dim.GetSize()
    3
    >>> group.OpenMDArray("time").ReadAsArray().tolist()
    [0.0, 1.0, 2.0]
    
  • Create a text coordinate axis (e.g. WRF Time stamps) via the string channel:
    >>> import numpy as np
    >>> from osgeo import gdal
    >>> from pyramids.netcdf import NetCDF
    >>> ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("mem")
    >>> group = ds.GetRootGroup()
    >>> sdt = gdal.ExtendedDataType.CreateString()
    >>> stamps = np.array(["2000-01-24_12:00:00", "2000-01-24_18:00:00"])
    >>> dim = NetCDF.create_main_dimension(group, "Time", sdt, stamps)
    >>> dim.GetSize()
    2
    >>> group.OpenMDArray("Time").Read()
    ['2000-01-24_12:00:00', '2000-01-24_18:00:00']
    
Source code in src/pyramids/netcdf/netcdf.py
@staticmethod
def create_main_dimension(
    group: gdal.Group, dim_name: str, dtype: Any, values: np.ndarray
) -> gdal.Dimension:
    """Create a NetCDF dimension with an indexing variable.

    The dimension type is inferred from `dim_name`:
    `y`/`lat`/`latitude` -> horizontal Y,
    `x`/`lon`/`longitude` -> horizontal X,
    `bands`/`time` -> temporal.

    The dimension is registered in the group together with a matching MDArray that
    stores the coordinate values. When `dtype` is a string `ExtendedDataType`
    (`gdal.ExtendedDataType.CreateString()`) a text coordinate axis — WRF's `Time` of
    `'2000-01-24_12:00:00'` stamps, a scenario name — is written through the GDAL string
    channel (a Python list of `str`); a NumPy string array sent straight to `Write` is
    force-cast to float64 by the SWIG binding and would raise (#1181).

    Args:
        group: Root group (or sub-group) of the multidimensional dataset.
        dim_name: Name of the dimension to create.
        dtype: GDAL `ExtendedDataType` for the indexing variable — a numeric type for a
            numeric axis, or `gdal.ExtendedDataType.CreateString()` for a text axis.
        values: Coordinate values for the dimension; numeric or text to match `dtype`.

    Returns:
        gdal.Dimension: The newly created dimension.

    Examples:
        - Create a numeric coordinate axis and read the stored values back:
            ```python
            >>> import numpy as np
            >>> from osgeo import gdal
            >>> from pyramids.netcdf import NetCDF
            >>> ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("mem")
            >>> group = ds.GetRootGroup()
            >>> f64 = gdal.ExtendedDataType.Create(gdal.GDT_Float64)
            >>> dim = NetCDF.create_main_dimension(group, "time", f64, np.array([0.0, 1.0, 2.0]))
            >>> dim.GetSize()
            3
            >>> group.OpenMDArray("time").ReadAsArray().tolist()
            [0.0, 1.0, 2.0]

            ```
        - Create a text coordinate axis (e.g. WRF `Time` stamps) via the string channel:
            ```python
            >>> import numpy as np
            >>> from osgeo import gdal
            >>> from pyramids.netcdf import NetCDF
            >>> ds = gdal.GetDriverByName("MEM").CreateMultiDimensional("mem")
            >>> group = ds.GetRootGroup()
            >>> sdt = gdal.ExtendedDataType.CreateString()
            >>> stamps = np.array(["2000-01-24_12:00:00", "2000-01-24_18:00:00"])
            >>> dim = NetCDF.create_main_dimension(group, "Time", sdt, stamps)
            >>> dim.GetSize()
            2
            >>> group.OpenMDArray("Time").Read()
            ['2000-01-24_12:00:00', '2000-01-24_18:00:00']

            ```
    """
    if dim_name in ["y", "lat", "latitude"]:
        dim_type = gdal.DIM_TYPE_HORIZONTAL_Y
    elif dim_name in ["x", "lon", "longitude"]:
        dim_type = gdal.DIM_TYPE_HORIZONTAL_X
    elif dim_name in ["bands", "time"]:
        dim_type = gdal.DIM_TYPE_TEMPORAL
    else:
        dim_type = None
    dim = group.CreateDimension(dim_name, dim_type, None, values.shape[0])
    x_values = group.CreateMDArray(dim_name, [dim], dtype)
    if dtype.GetClass() == gdal.GEDTC_STRING:
        # A string MDArray takes a Python list of `str`; a NumPy string array through
        # `Write` is force-cast to float64 by the SWIG binding and raises (#1181).
        x_values.Write(np.asarray(values).astype(str).tolist())
    else:
        x_values.Write(values)
    dim.SetIndexingVariable(x_values)
    return dim

from_array(arr, *, geo_ref, no_data_value=DEFAULT_NO_DATA_VALUE, path=None, variable_name=None, dims=None, encoding=None, attrs=None, spatial_names=None) classmethod #

Build a :class:Container from a NumPy array.

Declared with the engine's real parameters rather than *args, **kwargs. The bare form made an incompatible override invisible to static checkers, which is how this constructor came to accept a different keyword set from its own base class unnoticed.

The return type is :class:Container, not cls: this always builds a container regardless of the subtype it is invoked on. Container adds no public API over :class:NetCDF, so nothing is lost — but the annotation now says so instead of implying cls is honoured.

Parameters:

Name Type Description Default
arr ndarray

The array to wrap. 2-D is (rows, cols); the leading axes of a 3-D+ array are the non-spatial dimensions described by dims.

required
geo_ref GeoReference

How the array maps to space — an affine geo transform, or a top_left_corner + cell_size, plus the epsg. Required and keyword-only, exactly as on :meth:pyramids.dataset.Dataset.from_array; that shared core is what lets one caller build either class.

required
no_data_value Any | list

Sentinel marking cells outside the domain.

DEFAULT_NO_DATA_VALUE
path str | Path | None

Destination. Unlike :meth:pyramids.dataset.Dataset.from_array, the extension does not choose the format here: this builds a multidimensional store, which only the netCDF driver can carry, so None (default) builds it in memory and any path writes netCDF. The extension is validated rather than obeyed — it must name netCDF (.nc, or its .nc4 alias), and one naming another format raises instead of writing netCDF bytes under a misleading name.

None
variable_name str | None

Name for the created variable. Defaults to "data".

None
dims ExtraDimensions | None

Non-spatial (time / level / …) dimensions of a 3-D+ array. Ignored for 2-D arrays.

None
encoding Encoding | None

On-disk write options (chunking, compression); effective only with a path.

None
attrs CFAttributes | None

CF global attributes.

None
spatial_names tuple[str, str] | None

(row, column) names for the two spatial dimensions. None (default) names them y / x; a rebuild passes the source store's own so a result keeps latitude / longitude (#1180). Two distinct non-empty strings, or a ValueError.

None

Returns:

Name Type Description
Container Container

The newly created store.

Raises:

Type Description
ValueError

geo_ref carries neither a geo nor a complete top_left_corner + cell_size pair; spatial_names is not two distinct non-empty strings; or dims.attrs is keyed by a dimension this array does not have.

DriverNotExistError

path has no extension, or one the driver catalog does not know.

FileFormatNotSupportedError

path's extension names a driver other than netCDF (e.g. "out.tif"). Build the raster with :meth:pyramids.dataset.Dataset.from_array to write that format.

Examples:

  • The grid is named y / x unless the caller says otherwise, and spatial_names is how a rebuild keeps the source store's own names:

    >>> import numpy as np
    >>> from pyramids.netcdf import GeoReference, NetCDF
    >>> cells = np.arange(4.0).reshape(2, 2)
    >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326)
    >>> sorted(NetCDF.from_array(cells, geo_ref=geo_ref, variable_name="t").dimension_names)
    ['x', 'y']
    >>> named = NetCDF.from_array(
    ...     cells,
    ...     geo_ref=geo_ref,
    ...     variable_name="t",
    ...     spatial_names=("latitude", "longitude"),
    ... )
    >>> sorted(named.dimension_names)
    ['latitude', 'longitude']
    
    - A band dimension declares the CF units it is given, and they survive the write — which is what lets the written file decode its own stamps:

    >>> import numpy as np
    >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
    >>> cube = NetCDF.from_array(
    ...     np.arange(8.0).reshape(2, 2, 2),
    ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
    ...     variable_name="t",
    ...     dims=ExtraDimensions(
    ...         name="time",
    ...         values=[0.0, 6.0],
    ...         attrs={"time": {"units": "hours since 2000-01-01", "calendar": "standard"}},
    ...     ),
    ... )
    >>> cube.get_variable("t")._resolved_band_dim_time_attrs()
    {'time': ('hours since 2000-01-01', 'standard')}
    >>> cube.get_variable("t")._band_dim_values_map["time"]
    [0.0, 6.0]
    
    - CF attributes addressed to an axis the array does not have are refused, rather than quietly written nowhere:

    >>> import numpy as np
    >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
    >>> try:
    ...     NetCDF.from_array(
    ...         np.arange(8.0).reshape(2, 2, 2),
    ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
    ...         variable_name="t",
    ...         dims=ExtraDimensions(
    ...             name="time", values=[0.0, 6.0], attrs={"tim": {"units": "hours"}}
    ...         ),
    ...     )
    ... except ValueError as error:
    ...     print(error)
    attrs names 'tim', which is not among the dimensions of this array (time).
    
    - Wrap a 2-D array and read the variable back off the container:
    >>> import numpy as np
    >>> from pyramids.netcdf import NetCDF, GeoReference
    >>> container = NetCDF.from_array(
    ...     np.arange(6, dtype="float32").reshape(2, 3),
    ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0)),
    ...     variable_name="temperature",
    ... )
    >>> container.variable_names
    ['temperature']
    >>> container.get_variable("temperature").read_array().tolist()
    [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]
    
    - The same call shape works for :meth:pyramids.dataset.Dataset.from_array, which is the point of the shared core:
    >>> import numpy as np
    >>> from pyramids.dataset import Dataset
    >>> from pyramids.netcdf import NetCDF, GeoReference
    >>> ref = GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0))
    >>> arr = np.ones((2, 3), dtype="float32")
    >>> [
    ...     type(cls.from_array(arr, geo_ref=ref)).__name__
    ...     for cls in (Dataset, NetCDF)
    ... ]
    ['Dataset', 'Container']
    
    - A path naming another format is refused, not written under a misleading name:
    >>> import numpy as np
    >>> from pyramids.errors import FileFormatNotSupportedError
    >>> from pyramids.netcdf import NetCDF, GeoReference
    >>> try:
    ...     NetCDF.from_array(
    ...         np.zeros((2, 2), dtype="float32"),
    ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0)),
    ...         path="lies.tif",
    ...     )
    ... except FileFormatNotSupportedError as error:
    ...     print(str(error).split(", but ")[0])
    NetCDF.from_array writes a multidimensional netCDF store
    

Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def from_array(
    cls,
    arr: np.ndarray,
    *,
    geo_ref: GeoReference,
    no_data_value: Any | list = DEFAULT_NO_DATA_VALUE,
    path: str | Path | None = None,
    variable_name: str | None = None,
    dims: ExtraDimensions | None = None,
    encoding: Encoding | None = None,
    attrs: CFAttributes | None = None,
    spatial_names: tuple[str, str] | None = None,
) -> Container:
    """Build a :class:`Container` from a NumPy array.

    Declared with the engine's real parameters rather than ``*args,
    **kwargs``. The bare form made an incompatible override invisible to
    static checkers, which is how this constructor came to accept a
    different keyword set from its own base class unnoticed.

    The return type is :class:`Container`, not ``cls``: this always builds a
    container regardless of the subtype it is invoked on. ``Container`` adds
    no public API over :class:`NetCDF`, so nothing is lost — but the
    annotation now says so instead of implying ``cls`` is honoured.

    Args:
        arr: The array to wrap. 2-D is `(rows, cols)`; the leading axes of
            a 3-D+ array are the non-spatial dimensions described by
            `dims`.
        geo_ref: How the array maps to space — an affine ``geo`` transform,
            or a ``top_left_corner`` + ``cell_size``, plus the ``epsg``.
            Required and keyword-only, exactly as on
            :meth:`pyramids.dataset.Dataset.from_array`; that shared core
            is what lets one caller build either class.
        no_data_value: Sentinel marking cells outside the domain.
        path: Destination. Unlike
            :meth:`pyramids.dataset.Dataset.from_array`, the extension does
            not choose the format here: this builds a multidimensional
            store, which only the netCDF driver can carry, so `None`
            (default) builds it in memory and any path writes netCDF. The
            extension is validated rather than obeyed — it must name netCDF
            (`.nc`, or its `.nc4` alias), and one naming another format
            raises instead of writing netCDF bytes under a misleading name.
        variable_name: Name for the created variable. Defaults to
            ``"data"``.
        dims: Non-spatial (time / level / …) dimensions of a 3-D+ array.
            Ignored for 2-D arrays.
        encoding: On-disk write options (chunking, compression); effective
            only with a `path`.
        attrs: CF global attributes.
        spatial_names: `(row, column)` names for the two spatial dimensions.
            `None` (default) names them `y` / `x`; a rebuild passes the source
            store's own so a result keeps `latitude` / `longitude` (#1180).
            Two distinct non-empty strings, or a `ValueError`.

    Returns:
        Container: The newly created store.

    Raises:
        ValueError: `geo_ref` carries neither a ``geo`` nor a complete
            ``top_left_corner`` + ``cell_size`` pair; `spatial_names` is not
            two distinct non-empty strings; or `dims.attrs` is keyed by a
            dimension this array does not have.
        DriverNotExistError: `path` has no extension, or one the driver
            catalog does not know.
        FileFormatNotSupportedError: `path`'s extension names a driver
            other than netCDF (e.g. `"out.tif"`). Build the raster with
            :meth:`pyramids.dataset.Dataset.from_array` to write that
            format.

    Examples:
        - The grid is named `y` / `x` unless the caller says otherwise, and
          `spatial_names` is how a rebuild keeps the source store's own names:

            ```python
            >>> import numpy as np
            >>> from pyramids.netcdf import GeoReference, NetCDF
            >>> cells = np.arange(4.0).reshape(2, 2)
            >>> geo_ref = GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326)
            >>> sorted(NetCDF.from_array(cells, geo_ref=geo_ref, variable_name="t").dimension_names)
            ['x', 'y']
            >>> named = NetCDF.from_array(
            ...     cells,
            ...     geo_ref=geo_ref,
            ...     variable_name="t",
            ...     spatial_names=("latitude", "longitude"),
            ... )
            >>> sorted(named.dimension_names)
            ['latitude', 'longitude']

            ```
        - A band dimension declares the CF units it is given, and they survive the
          write — which is what lets the written file decode its own stamps:

            ```python
            >>> import numpy as np
            >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
            >>> cube = NetCDF.from_array(
            ...     np.arange(8.0).reshape(2, 2, 2),
            ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
            ...     variable_name="t",
            ...     dims=ExtraDimensions(
            ...         name="time",
            ...         values=[0.0, 6.0],
            ...         attrs={"time": {"units": "hours since 2000-01-01", "calendar": "standard"}},
            ...     ),
            ... )
            >>> cube.get_variable("t")._resolved_band_dim_time_attrs()
            {'time': ('hours since 2000-01-01', 'standard')}
            >>> cube.get_variable("t")._band_dim_values_map["time"]
            [0.0, 6.0]

            ```
        - CF attributes addressed to an axis the array does not have are refused,
          rather than quietly written nowhere:

            ```python
            >>> import numpy as np
            >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
            >>> try:
            ...     NetCDF.from_array(
            ...         np.arange(8.0).reshape(2, 2, 2),
            ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
            ...         variable_name="t",
            ...         dims=ExtraDimensions(
            ...             name="time", values=[0.0, 6.0], attrs={"tim": {"units": "hours"}}
            ...         ),
            ...     )
            ... except ValueError as error:
            ...     print(error)
            attrs names 'tim', which is not among the dimensions of this array (time).

            ```
        - Wrap a 2-D array and read the variable back off the container:
            ```python
            >>> import numpy as np
            >>> from pyramids.netcdf import NetCDF, GeoReference
            >>> container = NetCDF.from_array(
            ...     np.arange(6, dtype="float32").reshape(2, 3),
            ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0)),
            ...     variable_name="temperature",
            ... )
            >>> container.variable_names
            ['temperature']
            >>> container.get_variable("temperature").read_array().tolist()
            [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]

            ```
        - The same call shape works for
          :meth:`pyramids.dataset.Dataset.from_array`, which is the point
          of the shared core:
            ```python
            >>> import numpy as np
            >>> from pyramids.dataset import Dataset
            >>> from pyramids.netcdf import NetCDF, GeoReference
            >>> ref = GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0))
            >>> arr = np.ones((2, 3), dtype="float32")
            >>> [
            ...     type(cls.from_array(arr, geo_ref=ref)).__name__
            ...     for cls in (Dataset, NetCDF)
            ... ]
            ['Dataset', 'Container']

            ```
        - A path naming another format is refused, not written under a
          misleading name:
            ```python
            >>> import numpy as np
            >>> from pyramids.errors import FileFormatNotSupportedError
            >>> from pyramids.netcdf import NetCDF, GeoReference
            >>> try:
            ...     NetCDF.from_array(
            ...         np.zeros((2, 2), dtype="float32"),
            ...         geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0)),
            ...         path="lies.tif",
            ...     )
            ... except FileFormatNotSupportedError as error:
            ...     print(str(error).split(", but ")[0])
            NetCDF.from_array writes a multidimensional netCDF store

            ```
    """
    return _variables.from_array(
        arr,
        geo_ref=geo_ref,
        no_data_value=no_data_value,
        path=path,
        variable_name=variable_name,
        dims=dims,
        encoding=encoding,
        attrs=attrs,
        spatial_names=spatial_names,
    )

from_dataframe(df, *, crs=None, x=None, y=None, variables=None, no_data_value=np.nan, path=None) classmethod #

Build a :class:Container from a MultiIndex DataFrame — the inverse of to_dataframe.

The frame is indexed by its dimensions: the innermost two index levels are the (y, x) grid axes and any outer levels are band dimensions, so NetCDF.from_dataframe(nc.to_dataframe(), crs=nc.epsg) reproduces nc's values, variables, band coordinates, grid and CRS (the geotransform is recovered by differencing cell centres); gaps come back as NaN unless no_data_value= restores a sentinel. A DataFrame carries no georeferencing, so the geotransform is inferred from the x / y cell-centre coordinates (a regular grid is required; a single-row or single-column axis carries no spacing and is refused) and the CRS is taken from crs. The result is always north-up; band-dimension coordinates keep their first-appearance order. See the engine method for the full contract.

Parameters:

Name Type Description Default
df DataFrame

A DataFrame on a pandas.MultiIndex of at least two named levels. The innermost two are the row (y) and column (x) axes unless x / y name them; further levels are band dimensions, outermost first. Each non-index column becomes a data variable. A tidy frame on a plain index is refused.

required
crs str | int | None

The CRS for the result, an EPSG code or a CRS string. None (default) leaves it unset — a DataFrame carries none, so a full round trip needs crs=nc.epsg to recover it.

None
x str | None

The index level holding the column (x) coordinates; defaults to the innermost level.

None
y str | None

The index level holding the row (y) coordinates; defaults to the second-innermost level.

None
variables str | Sequence[str] | None

Which columns become data variables, as a name or a sequence; None (default) takes every column.

None
no_data_value Any

Sentinel stamped on gaps (NaN cells and cells absent from the frame). Defaults to np.nan, so gaps stay NaN and each rebuilt variable declares NaN as its nodata; pass no_data_value= to restore a sentinel.

nan
path str | Path | None

Destination — None (default) builds in memory, a .nc path writes it, as :meth:from_array.

None

Returns:

Name Type Description
Container Container

The rebuilt store, one variable per chosen column, on the inferred grid.

Raises:

Type Description
ValueError

df is not indexed by a MultiIndex of at least two named levels; a named x / y level is missing or the two coincide; there are no value columns or a requested one is absent; two selected columns stringify to the same variable name; the index has duplicate rows; or an x / y axis is irregular or has fewer than two coordinates.

Examples:

  • Round-trip a two-step cube through pandas and back:
>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> nc = NetCDF.from_array(
...     np.arange(8.0).reshape(2, 2, 2),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> back = NetCDF.from_dataframe(nc.to_dataframe(), crs=nc.epsg)
>>> back.get_variable("t")._band_dim_values_map["time"]
[0.0, 6.0]
>>> back.epsg
4326
Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def from_dataframe(
    cls,
    df: pd.DataFrame,
    *,
    crs: str | int | None = None,
    x: str | None = None,
    y: str | None = None,
    variables: str | Sequence[str] | None = None,
    no_data_value: Any = np.nan,
    path: str | Path | None = None,
) -> Container:
    """Build a :class:`Container` from a `MultiIndex` DataFrame — the inverse of `to_dataframe`.

    The frame is indexed by its dimensions: the innermost two index levels are the
    `(y, x)` grid axes and any outer levels are band dimensions, so
    `NetCDF.from_dataframe(nc.to_dataframe(), crs=nc.epsg)` reproduces `nc`'s values,
    variables, band coordinates, grid and CRS (the geotransform is recovered by
    differencing cell centres); gaps come back as `NaN` unless `no_data_value=` restores a
    sentinel. A DataFrame carries no georeferencing, so the geotransform is inferred from
    the `x` / `y` cell-centre coordinates (a regular grid is required; a single-row or
    single-column axis carries no spacing and is refused) and the CRS is taken from
    `crs`. The result is always north-up; band-dimension coordinates keep their
    first-appearance order. See the engine method for the full contract.

    Args:
        df: A DataFrame on a `pandas.MultiIndex` of at least two named levels. The
            innermost two are the row (`y`) and column (`x`) axes unless `x` / `y` name
            them; further levels are band dimensions, outermost first. Each non-index
            column becomes a data variable. A tidy frame on a plain index is refused.
        crs: The CRS for the result, an EPSG code or a CRS string. `None` (default)
            leaves it unset — a DataFrame carries none, so a full round trip needs
            `crs=nc.epsg` to recover it.
        x: The index level holding the column (x) coordinates; defaults to the innermost
            level.
        y: The index level holding the row (y) coordinates; defaults to the
            second-innermost level.
        variables: Which columns become data variables, as a name or a sequence; `None`
            (default) takes every column.
        no_data_value: Sentinel stamped on gaps (`NaN` cells and cells absent from the
            frame). Defaults to `np.nan`, so gaps stay `NaN` and each rebuilt variable
            declares `NaN` as its nodata; pass `no_data_value=` to restore a sentinel.
        path: Destination — `None` (default) builds in memory, a `.nc` path writes it,
            as :meth:`from_array`.

    Returns:
        Container: The rebuilt store, one variable per chosen column, on the inferred
            grid.

    Raises:
        ValueError: `df` is not indexed by a `MultiIndex` of at least two named levels;
            a named `x` / `y` level is missing or the two coincide; there are no value
            columns or a requested one is absent; two selected columns stringify to the
            same variable name; the index has duplicate rows; or an `x` / `y` axis is
            irregular or has fewer than two coordinates.

    Examples:
        - Round-trip a two-step cube through pandas and back:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> nc = NetCDF.from_array(
          ...     np.arange(8.0).reshape(2, 2, 2),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 2.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> back = NetCDF.from_dataframe(nc.to_dataframe(), crs=nc.epsg)
          >>> back.get_variable("t")._band_dim_values_map["time"]
          [0.0, 6.0]
          >>> back.epsg
          4326

          ```
    """
    return _variables.from_dataframe(
        df,
        crs=crs,
        x=x,
        y=y,
        variables=variables,
        no_data_value=no_data_value,
        path=path,
    )

set_global_attribute(name, value) #

Set a global attribute on the root group.

Creates or updates a single attribute on the root group.

Parameters:

Name Type Description Default
name str

Attribute name (e.g. "history", "Conventions").

required
value Any

Attribute value. Supports str, bool, int, float, and a numeric list/tuple — the latter is written as a real CF vector attribute (Float64[n]), not as its Python repr.

required

Raises:

Type Description
ValueError

If the dataset has no root group (not opened in MDIM mode).

Source code in src/pyramids/netcdf/netcdf.py
def set_global_attribute(self, name: str, value: Any):
    """Set a global attribute on the root group.

    Creates or updates a single attribute on the root group.

    Args:
        name: Attribute name (e.g. `"history"`,
            `"Conventions"`).
        value: Attribute value. Supports str, bool, int, float, and a numeric
            `list`/`tuple` — the latter is written as a real CF vector attribute
            (`Float64[n]`), not as its Python repr.

    Raises:
        ValueError: If the dataset has no root group
            (not opened in MDIM mode).
    """
    rg = self._working_group()
    if rg is None:
        raise ValueError(
            "set_global_attribute requires a multidimensional "
            "container. Open the file with "
            "open_as_multi_dimensional=True."
        )
    # Through the shared `cf` dispatch so a numeric sequence becomes a CF vector
    # attribute. `overwrite=True` keeps this member an upsert (GDAL refuses a duplicate,
    # so the attribute is deleted first).
    write_single_attr(rg, name, value, overwrite=True)
    self._invalidate_caches()

delete_global_attribute(name) #

Delete a global attribute from the root group.

If the attribute does not exist, the call is silently ignored.

Parameters:

Name Type Description Default
name str

Attribute name to delete.

required

Raises:

Type Description
ValueError

If the dataset has no root group.

Source code in src/pyramids/netcdf/netcdf.py
def delete_global_attribute(self, name: str):
    """Delete a global attribute from the root group.

    If the attribute does not exist, the call is silently ignored.

    Args:
        name: Attribute name to delete.

    Raises:
        ValueError: If the dataset has no root group.
    """
    rg = self._working_group()
    if rg is None:
        raise ValueError(
            "delete_global_attribute requires a multidimensional container."
        )
    try:
        rg.DeleteAttribute(name)
    except RuntimeError:
        pass  # attribute may not exist — silently ignored
    self._invalidate_caches()

set_variable(*args, **kwargs) #

Facade — :meth:Variables.set_variable <pyramids.netcdf.engines.variables.Variables.set_variable>.

Source code in src/pyramids/netcdf/netcdf.py
def set_variable(self, *args, **kwargs):
    """Facade — :meth:`Variables.set_variable <pyramids.netcdf.engines.variables.Variables.set_variable>`."""
    return self.varops.set_variable(*args, **kwargs)

crop_variable(variable_name, mask, touch=True) #

Crop a single variable and store the result back.

Convenience method that combines get_variable → crop → set_variable in one call.

Parameters:

Name Type Description Default
variable_name str

Name of the variable to crop.

required
mask Any

GeoDataFrame with polygon geometry, or a Dataset to use as a spatial mask.

required
touch bool

If True, include cells touching the mask boundary. Defaults to True.

True

Returns:

Name Type Description
NetCDF NetCDF

This container (modified in-place).

Source code in src/pyramids/netcdf/netcdf.py
def crop_variable(
    self, variable_name: str, mask: Any, touch: bool = True
) -> NetCDF:
    """Crop a single variable and store the result back.

    Convenience method that combines `get_variable` → `crop`
    → `set_variable` in one call.

    Args:
        variable_name: Name of the variable to crop.
        mask: GeoDataFrame with polygon geometry, or a Dataset
            to use as a spatial mask.
        touch: If True, include cells touching the mask boundary.
            Defaults to True.

    Returns:
        NetCDF: This container (modified in-place).
    """
    var = self._require_raster_variable(variable_name)
    cropped = var.crop(mask, touch=touch)
    self.set_variable(variable_name, cropped)
    return self

reproject_variable(variable_name, to_epsg, method=DEFAULT_RESAMPLING) #

Reproject a single variable and store the result back.

Convenience method that combines get_variable → to_crs → set_variable in one call.

The warp moves the variable's stored values, and the rebuilt variable is written back with the same CF packing (scale_factor / add_offset), so a packed variable stays packed at its stored dtype and still reads back in physical units.

Parameters:

Name Type Description Default
variable_name str

Name of the variable to reproject.

required
to_epsg int

Target EPSG code (e.g. 4326, 32637).

required
method str

Resampling method. Defaults to "nearest neighbor".

DEFAULT_RESAMPLING

Returns:

Name Type Description
NetCDF NetCDF

This container (modified in-place).

Raises:

Type Description
CRSError

The variable has no CRS to reproject from.

Examples:

  • Reproject a packed variable; it keeps its packing and its units:
    >>> import numpy as np
    >>> from pyramids.dataset import Dataset, GeoReference
    >>> from pyramids.netcdf import NetCDF
    >>> geo_ref = GeoReference(top_left_corner=(0, 1), cell_size=0.1, epsg=4326)
    >>> nc = NetCDF.from_array(
    ...     arr=np.zeros((10, 10), dtype="int16"), geo_ref=geo_ref, variable_name="t"
    ... )
    >>> packed = Dataset.from_array(np.full((10, 10), 100, dtype="int16"), geo_ref=geo_ref)
    >>> packed.scale = [0.01]
    >>> nc.set_variable("t", packed)
    >>> _ = nc.reproject_variable("t", 3857)
    >>> t = nc.get_variable("t")
    >>> t.epsg, t.read_array(unpack=False).dtype
    (3857, dtype('int16'))
    >>> float(t.read_array(masked=True).max())
    1.0
    
Source code in src/pyramids/netcdf/netcdf.py
def reproject_variable(
    self, variable_name: str, to_epsg: int, method: str = DEFAULT_RESAMPLING
) -> NetCDF:
    """Reproject a single variable and store the result back.

    Convenience method that combines `get_variable` → `to_crs`
    → `set_variable` in one call.

    The warp moves the variable's stored values, and the rebuilt variable
    is written back with the same CF packing (`scale_factor` /
    `add_offset`), so a packed variable stays packed at its stored dtype
    and still reads back in physical units.

    Args:
        variable_name: Name of the variable to reproject.
        to_epsg: Target EPSG code (e.g. 4326, 32637).
        method: Resampling method. Defaults to
            `"nearest neighbor"`.

    Returns:
        NetCDF: This container (modified in-place).

    Raises:
        CRSError: The variable has no CRS to reproject from.

    Examples:
        - Reproject a packed variable; it keeps its packing and its units:
            ```python
            >>> import numpy as np
            >>> from pyramids.dataset import Dataset, GeoReference
            >>> from pyramids.netcdf import NetCDF
            >>> geo_ref = GeoReference(top_left_corner=(0, 1), cell_size=0.1, epsg=4326)
            >>> nc = NetCDF.from_array(
            ...     arr=np.zeros((10, 10), dtype="int16"), geo_ref=geo_ref, variable_name="t"
            ... )
            >>> packed = Dataset.from_array(np.full((10, 10), 100, dtype="int16"), geo_ref=geo_ref)
            >>> packed.scale = [0.01]
            >>> nc.set_variable("t", packed)
            >>> _ = nc.reproject_variable("t", 3857)
            >>> t = nc.get_variable("t")
            >>> t.epsg, t.read_array(unpack=False).dtype
            (3857, dtype('int16'))
            >>> float(t.read_array(masked=True).max())
            1.0

            ```
    """
    var = self._require_raster_variable(variable_name)
    reprojected = var.to_crs(to_epsg, method=method)
    # to_crs returns a VRT-backed dataset — materialize it into
    # a MEM dataset so the data survives after the VRT source
    # (the variable subset) is garbage collected.
    #
    # `unpack=False`: the materialize is a copy of the warped store, and the
    # packing is carried onto it below, so `set_variable` writes the counts that
    # recipe describes rather than a `float64` widening of them. `squeeze=True` keeps the
    # classic flattened `(bands, rows, cols)` `from_array` and the `set_variable` below
    # expect, not the dimension-preserving default (#1241).
    arr = reprojected.read_array(unpack=False, squeeze=True)
    no_data_value = reprojected.no_data_value
    ndv_scalar = (
        no_data_value[0]
        if isinstance(no_data_value, (list, tuple)) and no_data_value
        else no_data_value
    )
    # epsg is None only for a no-EPSG CRS reported as such (a NetCDF
    # geostationary grid), and from_array raises CRSError on None, so fall
    # back to the WKT (#706). to_crs(to_epsg: int, ...) always targets a
    # concrete EPSG here, so epsg is provably non-None -- the fallback is
    # defense-in-depth, matching the pattern used throughout pyramids.dataset.
    materialized = Dataset.from_array(
        cast("np.typing.NDArray", arr),
        geo_ref=GeoReference(
            geo=reprojected.geotransform,
            epsg=crs_spec(reprojected.epsg, reprojected.crs),
        ),
        no_data_value=ndv_scalar,
    )
    carry_packing(reprojected.raster, materialized.raster)
    NetCDF._copy_band_dim_metadata(materialized, var)
    materialized._variable_attrs = var._variable_attrs
    self.set_variable(variable_name, materialized)
    return self

resample_variable(variable_name, cell_size, method=DEFAULT_RESAMPLING) #

Resample a single variable and store the result back.

Convenience method that combines get_variable → resample → set_variable in one call.

Parameters:

Name Type Description Default
variable_name str

Name of the variable to resample.

required
cell_size int | float

New cell size.

required
method str

Resampling method. Defaults to "nearest neighbor".

DEFAULT_RESAMPLING

Returns:

Name Type Description
NetCDF NetCDF

This container (modified in-place).

Source code in src/pyramids/netcdf/netcdf.py
def resample_variable(
    self,
    variable_name: str,
    cell_size: int | float,
    method: str = DEFAULT_RESAMPLING,
) -> NetCDF:
    """Resample a single variable and store the result back.

    Convenience method that combines `get_variable` → `resample`
    → `set_variable` in one call.

    Args:
        variable_name: Name of the variable to resample.
        cell_size: New cell size.
        method: Resampling method. Defaults to
            `"nearest neighbor"`.

    Returns:
        NetCDF: This container (modified in-place).
    """
    var = self._require_raster_variable(variable_name)
    resampled = var.resample(cell_size, method=method)
    self.set_variable(variable_name, resampled)
    return self

add_variable(*args, **kwargs) #

Facade — :meth:Variables.add_variable <pyramids.netcdf.engines.variables.Variables.add_variable>.

Source code in src/pyramids/netcdf/netcdf.py
def add_variable(self, *args, **kwargs):
    """Facade — :meth:`Variables.add_variable <pyramids.netcdf.engines.variables.Variables.add_variable>`."""
    return self.varops.add_variable(*args, **kwargs)

remove_variable(*args, **kwargs) #

Facade — :meth:remove_variable <pyramids.netcdf.engines.variables.Variables.remove_variable>.

Source code in src/pyramids/netcdf/netcdf.py
def remove_variable(self, *args, **kwargs):
    """Facade — :meth:`remove_variable <pyramids.netcdf.engines.variables.Variables.remove_variable>`."""
    return self.varops.remove_variable(*args, **kwargs)

rename_variable(*args, **kwargs) #

Facade — :meth:rename_variable <pyramids.netcdf.engines.variables.Variables.rename_variable>.

Source code in src/pyramids/netcdf/netcdf.py
def rename_variable(self, *args, **kwargs):
    """Facade — :meth:`rename_variable <pyramids.netcdf.engines.variables.Variables.rename_variable>`."""
    return self.varops.rename_variable(*args, **kwargs)

to_xarray(chunks=None, *, decode_times=True) #

Facade — delegates to :meth:Interop.to_xarray <pyramids.netcdf.engines.interop.Interop.to_xarray>.

Spelled out rather than *args, **kwargs so the keywords are visible where callers look for them: the rendered reference, help(NetCDF.to_xarray) and editor completion all read the facade, not the engine behind it.

Parameters:

Name Type Description Default
chunks dict | str | int | None

Dask chunking for a lazy read, or None to read eagerly.

None
decode_times bool

Whether to decode a CF time axis to datetime64[ns]. False returns the stored offsets.

True

Returns:

Type Description
Any

xarray.Dataset: The exported cube. See the engine method for the full

Any

contract.

Raises:

Type Description
OptionalPackageDoesNotExist

xarray is not installed.

ImportError

chunks was given but the [lazy] (dask) extra is not installed.

ValueError

This container is not multidimensional — open the file with open_as_multi_dimensional=True.

Warns:

Type Description
TimeDecodingWarning

A dimension declares CF time units that could not be decoded, so it was exported as its stored offsets.

Examples:

  • Export a CF container and group its cube by a time component:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> xds = nc.to_xarray()
>>> str(xds.coords["time"].dtype)
'datetime64[ns]'
>>> xds["temperature"].groupby("time.month").mean().shape
(1, 3, 5, 6)
- Keep the axis as the offsets the file stores:

>>> from pyramids.netcdf import NetCDF
>>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
>>> raw = nc.to_xarray(decode_times=False)
>>> [float(offset) for offset in raw.coords["time"].values]
[0.0, 6.0, 12.0, 18.0]
See Also

NetCDF.from_xarray: The inverse, which writes a decoded axis back in the CF units this put in its encoding.

Source code in src/pyramids/netcdf/netcdf.py
def to_xarray(
    self, chunks: dict | str | int | None = None, *, decode_times: bool = True
) -> Any:
    """Facade — delegates to :meth:`Interop.to_xarray <pyramids.netcdf.engines.interop.Interop.to_xarray>`.

    Spelled out rather than `*args, **kwargs` so the keywords are visible where
    callers look for them: the rendered reference, `help(NetCDF.to_xarray)` and
    editor completion all read the facade, not the engine behind it.

    Args:
        chunks: Dask chunking for a lazy read, or `None` to read eagerly.
        decode_times: Whether to decode a CF time axis to `datetime64[ns]`. `False`
            returns the stored offsets.

    Returns:
        xarray.Dataset: The exported cube. See the engine method for the full
        contract.

    Raises:
        pyramids.base._errors.OptionalPackageDoesNotExist: `xarray` is not installed.
        ImportError: `chunks` was given but the `[lazy]` (dask) extra is not installed.
        ValueError: This container is not multidimensional — open the file with
            `open_as_multi_dimensional=True`.

    Warns:
        TimeDecodingWarning: A dimension declares CF time `units` that could not be
            decoded, so it was exported as its stored offsets.

    Examples:
        - Export a CF container and group its cube by a time component:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> xds = nc.to_xarray()
          >>> str(xds.coords["time"].dtype)
          'datetime64[ns]'
          >>> xds["temperature"].groupby("time.month").mean().shape
          (1, 3, 5, 6)

          ```
        - Keep the axis as the offsets the file stores:

          ```python
          >>> from pyramids.netcdf import NetCDF
          >>> nc = NetCDF.read_file("tests/data/netcdf/cf__5v__1d4-4d1__y-asc.nc")
          >>> raw = nc.to_xarray(decode_times=False)
          >>> [float(offset) for offset in raw.coords["time"].values]
          [0.0, 6.0, 12.0, 18.0]

          ```

    See Also:
        NetCDF.from_xarray: The inverse, which writes a decoded axis back in the
            CF units this put in its `encoding`.
    """
    return self.interop.to_xarray(chunks, decode_times=decode_times)

subset(*args, **kwargs) #

Facade — :meth:Selection.subset <pyramids.netcdf.engines.selection.Selection.subset>.

Source code in src/pyramids/netcdf/netcdf.py
def subset(self, *args, **kwargs) -> NetCDF:
    """Facade — :meth:`Selection.subset <pyramids.netcdf.engines.selection.Selection.subset>`."""
    return self.selection.subset(*args, **kwargs)

from_xarray(dataset, path=None) classmethod #

Facade — delegates to :func:pyramids.netcdf.engines.interop.from_xarray.

See that function for the full contract. from_xarray builds a new container (it does not operate on an existing instance), so its body lives in the module-level engine function rather than on the instance-bound :class:~pyramids.netcdf.engines.interop.Interop engine; cls is threaded through so the file is read back as the concrete Container subtype.

Source code in src/pyramids/netcdf/netcdf.py
@classmethod
def from_xarray(
    cls,
    dataset: Any,
    path: str | Path | None = None,
) -> NetCDF:
    """Facade — delegates to :func:`pyramids.netcdf.engines.interop.from_xarray`.

    See that function for the full contract. ``from_xarray`` builds a new
    container (it does not operate on an existing instance), so its body
    lives in the module-level engine function rather than on the
    instance-bound :class:`~pyramids.netcdf.engines.interop.Interop` engine;
    ``cls`` is threaded through so the file is read back as the concrete
    ``Container`` subtype.
    """
    return _interop.from_xarray(cls, dataset, path)