Skip to content

FDSN — API reference#

FDSN seismic-event data source subpackage — earthlens.fdsn. Background and usage are covered under the other pages in this section (Introduction, Usage); this page is the rendered API.

earthlens.fdsn #

FDSN seismic-event backend.

Thin wrapper over obspy.clients.fdsn that queries the IRIS FDSN-event web service across six seismological networks — USGS (ComCat), EMSC (seismicportal), INGV (Italian seismic + volcano), EarthScope (ex-IRIS DMC), ISC (global reviewed bulletin), and GeoNet (New Zealand) — and returns the matched events as a pyramids :class:~pyramids.feature.collection.FeatureCollection of point features (CRS EPSG:4326).

This is the package's first vector backend: the result is a table of events, not a gridded array, so :data:FDSN.OUTPUT_KIND is "vector" and an aggregate= argument is refused — by the shared guard in :class:earthlens.base.AbstractDataSource, so a direct FDSN(...).download(aggregate=...) is rejected identically to one made through the :class:earthlens.earthlens.EarthLens facade.

Provider selection: for this backend variables is a list[str] of network keys — variables=["USGS"], variables=["USGS", "EMSC"]not data-variable names. This is an intentional, documented overload (the facade makes variables a required argument, so an extra providers= kwarg would only add placeholder noise). Query filters (min_magnitude, max_depth, event_type, …) arrive as explicit :class:FDSN constructor keyword arguments.

Public surface (re-exported from this package):

  • :class:FDSN — the backend; instantiate with a date range, a bbox, and variables=[network, ...], then call :meth:FDSN.download.
  • :class:Catalog — pydantic-backed loader for the bundled fdsn_data_catalog.yaml provider dispatch table.
  • :class:Provider — one network's dispatch row (fdsn_id, title, needs_token, default min-magnitude, docs URL).
  • :func:catalog_to_fc / :func:empty_fc — the obspy.Catalog → FeatureCollection mapper and its empty-result counterpart.
  • :func:resolve_earthscope_token — optional EarthScope-token resolver (env / file); the public event services need no token.
  • :data:SHAKEMAP_LAYERS / :data:DEFAULT_SHAKEMAP_LAYERS — the fourteen ShakeMap grids with_shakemap=True can write, and the one written by default.
  • :data:CATALOG_PATH — path to the bundled provider YAML.

Examples:

  • List the registered networks:

    >>> from earthlens.fdsn import Catalog
    >>> sorted(Catalog().providers)
    ['EARTHSCOPE', 'EMSC', 'GEONET', 'INGV', 'ISC', 'USGS']
    

Catalog #

Bases: AbstractCatalog

Provider catalog for the FDSN seismic-event backend.

Reads the bundled fdsn_data_catalog.yaml (shipped as package data) and exposes its providers: block as a map of :class:Provider rows. Instantiate with no arguments (Catalog()); :func:model_post_init loads and validates the YAML (cached) in one pass.

The rows live in the framework's :attr:datasets field so the inherited dict-like surface behaves like every other backend's catalog — len(cat), name in cat, cat[name], iter(cat) and :meth:get_dataset all work. The same rows are mirrored onto :attr:providers, so the domain-friendly :meth:get_provider and cat.providers work too; the two are aliases of one another.

Attributes:

Name Type Description
datasets dict[str, Provider]

Map from the user-facing network name to its :class:Provider dispatch row (the framework field).

providers dict[str, Provider]

Alias of :attr:datasets — same rows, kept for the FDSN "network / provider" vocabulary.

Examples:

  • The dict-like surface works like the other backends:
    >>> from earthlens.fdsn import Catalog
    >>> cat = Catalog()
    >>> len(cat)
    6
    >>> "USGS" in cat
    True
    >>> cat["USGS"].fdsn_id
    'USGS'
    
  • datasets and providers are the same rows; resolve via either accessor:
    >>> from earthlens.fdsn import Catalog
    >>> cat = Catalog()
    >>> sorted(cat.providers) == sorted(cat.datasets)
    True
    >>> cat.get_provider("USGS").fdsn_id
    'USGS'
    >>> cat.get_dataset("EMSC").fdsn_id
    'EMSC'
    
  • An unknown network raises with a did-you-mean hint:
    >>> from earthlens.fdsn import Catalog
    >>> Catalog().get_provider("USG")
    Traceback (most recent call last):
        ...
    ValueError: 'USG' is not a registered provider. Known providers: ['EARTHSCOPE', 'EMSC', 'GEONET', 'INGV', 'ISC', 'USGS']. Did you mean 'USGS'?
    
Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
class Catalog(AbstractCatalog):
    """Provider catalog for the FDSN seismic-event backend.

    Reads the bundled `fdsn_data_catalog.yaml` (shipped as package
    data) and exposes its `providers:` block as a map of
    :class:`Provider` rows. Instantiate with no arguments
    (`Catalog()`); :func:`model_post_init` loads and validates the YAML
    (cached) in one pass.

    The rows live in the framework's :attr:`datasets` field so the
    inherited dict-like surface behaves like every other backend's
    catalog — `len(cat)`, `name in cat`, `cat[name]`, `iter(cat)` and
    :meth:`get_dataset` all work. The same rows are mirrored onto
    :attr:`providers`, so the domain-friendly :meth:`get_provider` and
    `cat.providers` work too; the two are aliases of one another.

    Attributes:
        datasets: Map from the user-facing network name to its
            :class:`Provider` dispatch row (the framework field).
        providers: Alias of :attr:`datasets` — same rows, kept for the
            FDSN "network / provider" vocabulary.

    Examples:
        - The dict-like surface works like the other backends:
            ```python
            >>> from earthlens.fdsn import Catalog
            >>> cat = Catalog()
            >>> len(cat)
            6
            >>> "USGS" in cat
            True
            >>> cat["USGS"].fdsn_id
            'USGS'

            ```
        - `datasets` and `providers` are the same rows; resolve via
          either accessor:
            ```python
            >>> from earthlens.fdsn import Catalog
            >>> cat = Catalog()
            >>> sorted(cat.providers) == sorted(cat.datasets)
            True
            >>> cat.get_provider("USGS").fdsn_id
            'USGS'
            >>> cat.get_dataset("EMSC").fdsn_id
            'EMSC'

            ```
        - An unknown network raises with a did-you-mean hint:
            ```python
            >>> from earthlens.fdsn import Catalog
            >>> Catalog().get_provider("USG")
            Traceback (most recent call last):
                ...
            ValueError: 'USG' is not a registered provider. Known providers: ['EARTHSCOPE', 'EMSC', 'GEONET', 'INGV', 'ISC', 'USGS']. Did you mean 'USGS'?

            ```
    """

    _catalog_kind: str = "FDSN catalog"
    _entry_noun: str = "networks"

    datasets: dict[str, Provider] = Field(default_factory=dict)
    providers: dict[str, Provider] = Field(default_factory=dict)

    def model_post_init(self, __context: Any) -> None:
        """Auto-load the bundled catalog and keep `datasets`/`providers` in sync.

        `Catalog()` with no args reads :data:`CATALOG_PATH` (cached).
        Passing either `datasets=...` or `providers=...` skips the disk
        read (used in tests); whichever was supplied is mirrored onto
        the other so both accessors stay consistent.

        Raises:
            ValueError: Propagated from :func:`_load_catalog_data` when
                the YAML is missing, empty, or has a malformed row.
        """
        if self.datasets or self.providers:
            if not self.datasets:
                self.datasets = self.providers
            if not self.providers:
                self.providers = self.datasets
        else:
            rows = _load_catalog_data(CATALOG_PATH)
            self.datasets = dict(rows)
            self.providers = self.datasets
        super().model_post_init(__context)

    @classmethod
    def load(cls, catalog_path: Path | None = None) -> Catalog:
        """Read the FDSN provider catalog from disk (cached).

        Args:
            catalog_path: Path to the catalog YAML. Defaults to the
                module-level :data:`CATALOG_PATH`.

        Returns:
            A fully-populated :class:`Catalog`.

        Raises:
            ValueError: If the file has no `providers:` block, or a row
                fails :class:`Provider` validation.
        """
        catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
        rows = _load_catalog_data(catalog_path)
        return cls(datasets=dict(rows))

    def get_catalog(self) -> dict[str, Provider]:
        """Return the network map (satisfies the abstract contract).

        Returns:
            dict[str, Provider]: Same object as :attr:`datasets`.
        """
        return self.datasets

get_catalog() #

Return the network map (satisfies the abstract contract).

Returns:

Type Description
dict[str, Provider]

dict[str, Provider]: Same object as :attr:datasets.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
def get_catalog(self) -> dict[str, Provider]:
    """Return the network map (satisfies the abstract contract).

    Returns:
        dict[str, Provider]: Same object as :attr:`datasets`.
    """
    return self.datasets

load(catalog_path=None) classmethod #

Read the FDSN provider catalog from disk (cached).

Parameters:

Name Type Description Default
catalog_path Path | None

Path to the catalog YAML. Defaults to the module-level :data:CATALOG_PATH.

None

Returns:

Type Description
Catalog

A fully-populated :class:Catalog.

Raises:

Type Description
ValueError

If the file has no providers: block, or a row fails :class:Provider validation.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
@classmethod
def load(cls, catalog_path: Path | None = None) -> Catalog:
    """Read the FDSN provider catalog from disk (cached).

    Args:
        catalog_path: Path to the catalog YAML. Defaults to the
            module-level :data:`CATALOG_PATH`.

    Returns:
        A fully-populated :class:`Catalog`.

    Raises:
        ValueError: If the file has no `providers:` block, or a row
            fails :class:`Provider` validation.
    """
    catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
    rows = _load_catalog_data(catalog_path)
    return cls(datasets=dict(rows))

model_post_init(__context) #

Auto-load the bundled catalog and keep datasets/providers in sync.

Catalog() with no args reads :data:CATALOG_PATH (cached). Passing either datasets=... or providers=... skips the disk read (used in tests); whichever was supplied is mirrored onto the other so both accessors stay consistent.

Raises:

Type Description
ValueError

Propagated from :func:_load_catalog_data when the YAML is missing, empty, or has a malformed row.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
def model_post_init(self, __context: Any) -> None:
    """Auto-load the bundled catalog and keep `datasets`/`providers` in sync.

    `Catalog()` with no args reads :data:`CATALOG_PATH` (cached).
    Passing either `datasets=...` or `providers=...` skips the disk
    read (used in tests); whichever was supplied is mirrored onto
    the other so both accessors stay consistent.

    Raises:
        ValueError: Propagated from :func:`_load_catalog_data` when
            the YAML is missing, empty, or has a malformed row.
    """
    if self.datasets or self.providers:
        if not self.datasets:
            self.datasets = self.providers
        if not self.providers:
            self.providers = self.datasets
    else:
        rows = _load_catalog_data(CATALOG_PATH)
        self.datasets = dict(rows)
        self.providers = self.datasets
    super().model_post_init(__context)

FDSN #

Bases: AbstractDataSource

FDSN seismic-event backend (vector point-feature output).

Wraps obspy.clients.fdsn.Client.get_events so a user can pull a space/time/magnitude window of seismic events from one or more FDSN networks through the same download() shape every other earthlens backend uses. Each network key in variables becomes one server-side query; the per-network catalog is mapped to a :class:~pyramids.feature.collection.FeatureCollection and the networks' results are unioned into the single FeatureCollection download() returns.

The public event services need no credentials; the optional EarthScope token is resolved lazily and only used for a provider whose catalog row declares needs_token: true.

Attributes:

Name Type Description
OUTPUT_KIND OutputKind

"vector" — the result is a table of point features (events), so aggregate= is refused with NotImplementedError. It stays "vector" even under with_shakemap=True: OUTPUT_KIND describes what download() returns, and that is always a FeatureCollection. The ShakeMap GeoTIFFs are an on-disk side effect, not a second return shape — "mixed" is reserved for a backend whose returned format is only known at download time and which honours aggregate= itself.

Examples:

  • Build a plain event query and inspect what it resolved:
    >>> from earthlens.fdsn import FDSN
    >>> backend = FDSN(
    ...     start="2024-01-01",
    ...     end="2024-01-31",
    ...     variables=["USGS"],
    ...     lat_lim=[30.0, 45.0],
    ...     lon_lim=[130.0, 145.0],
    ... )
    >>> backend.vars
    ['USGS']
    >>> backend.time.resolution
    'all'
    >>> backend.space.south, backend.space.east
    (30.0, 145.0)
    
  • An empty network list falls back to USGS, and the products the query will issue carry the resolved obspy client id:
    >>> from earthlens.fdsn import FDSN
    >>> backend = FDSN(
    ...     start="2024-01-01",
    ...     end="2024-01-02",
    ...     variables=[],
    ...     lat_lim=[-90.0, 90.0],
    ...     lon_lim=[-180.0, 180.0],
    ... )
    >>> backend.vars
    ['USGS']
    >>> [product.metadata["fdsn_id"] for product in backend._search()]
    ['USGS']
    
  • Asking for the ShakeMap side-output selects one grid by default and leaves the returned shape a vector table:
    >>> from earthlens.fdsn import FDSN
    >>> backend = FDSN(
    ...     start="2023-02-06",
    ...     end="2023-02-07",
    ...     variables=["USGS"],
    ...     lat_lim=[35.0, 39.0],
    ...     lon_lim=[35.0, 39.0],
    ...     with_shakemap=True,
    ... )
    >>> backend.OUTPUT_KIND
    'vector'
    >>> backend._shakemap_layers
    ('mmi_mean',)
    
Source code in libs/providers/hazards/src/earthlens/fdsn/backend.py
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
class FDSN(AbstractDataSource):
    """FDSN seismic-event backend (vector point-feature output).

    Wraps `obspy.clients.fdsn.Client.get_events` so a user can pull a
    space/time/magnitude window of seismic events from one or more
    FDSN networks through the same `download()` shape every other
    earthlens backend uses. Each network key in `variables` becomes
    one server-side query; the per-network catalog is mapped to a
    :class:`~pyramids.feature.collection.FeatureCollection` and the
    networks' results are unioned into the single FeatureCollection
    `download()` returns.

    The public event services need no credentials; the optional
    EarthScope token is resolved lazily and only used for a provider
    whose catalog row declares `needs_token: true`.

    Attributes:
        OUTPUT_KIND: `"vector"` — the result is a table of point
            features (events), so `aggregate=` is refused with
            `NotImplementedError`. It stays `"vector"` even under
            `with_shakemap=True`: `OUTPUT_KIND` describes what
            `download()` *returns*, and that is always a
            FeatureCollection. The ShakeMap GeoTIFFs are an on-disk
            side effect, not a second return shape — `"mixed"` is
            reserved for a backend whose returned format is only known
            at download time and which honours `aggregate=` itself.

    Examples:
        - Build a plain event query and inspect what it resolved:
            ```python
            >>> from earthlens.fdsn import FDSN
            >>> backend = FDSN(
            ...     start="2024-01-01",
            ...     end="2024-01-31",
            ...     variables=["USGS"],
            ...     lat_lim=[30.0, 45.0],
            ...     lon_lim=[130.0, 145.0],
            ... )
            >>> backend.vars
            ['USGS']
            >>> backend.time.resolution
            'all'
            >>> backend.space.south, backend.space.east
            (30.0, 145.0)

            ```
        - An empty network list falls back to USGS, and the products the
          query will issue carry the resolved obspy client id:
            ```python
            >>> from earthlens.fdsn import FDSN
            >>> backend = FDSN(
            ...     start="2024-01-01",
            ...     end="2024-01-02",
            ...     variables=[],
            ...     lat_lim=[-90.0, 90.0],
            ...     lon_lim=[-180.0, 180.0],
            ... )
            >>> backend.vars
            ['USGS']
            >>> [product.metadata["fdsn_id"] for product in backend._search()]
            ['USGS']

            ```
        - Asking for the ShakeMap side-output selects one grid by default
          and leaves the returned shape a vector table:
            ```python
            >>> from earthlens.fdsn import FDSN
            >>> backend = FDSN(
            ...     start="2023-02-06",
            ...     end="2023-02-07",
            ...     variables=["USGS"],
            ...     lat_lim=[35.0, 39.0],
            ...     lon_lim=[35.0, 39.0],
            ...     with_shakemap=True,
            ... )
            >>> backend.OUTPUT_KIND
            'vector'
            >>> backend._shakemap_layers
            ('mmi_mean',)

            ```
    """

    OUTPUT_KIND: OutputKind = "vector"

    AGGREGATE_REFUSAL_REASON = "seismic events are vector point features, not gridded rasters, so there is no meaningful gridded reduction — and the optional with_shakemap= rasters are a per-event side-output with no time axis to reduce over. Call download() without aggregate= and post-process the returned FeatureCollection (a GeoDataFrame) directly"

    #: Partial-failure policy for the per-provider loop; `download(errors=...)`
    #: overrides it per call.
    _errors: str = "warn"

    #: Whether `download(force=...)` asked for cached ShakeMap rasters to be
    #: refetched rather than reused.
    _force: bool = False

    def __init__(
        self,
        start: str,
        end: str,
        variables: list[str],
        lat_lim: list[float],
        lon_lim: list[float],
        temporal_resolution: str = "all",
        path: Path | str | None = None,
        fmt: str = "%Y-%m-%d",
        min_magnitude: float | None = None,
        max_magnitude: float | None = None,
        min_depth: float | None = None,
        max_depth: float | None = None,
        magnitude_type: str | None = None,
        event_type: str | None = None,
        orderby: str = "time",
        limit: int | None = None,
        earthscope_token: str | None = None,
        file_format: FileFormat = "gpkg",
        with_shakemap: bool = False,
        shakemap_layers: list[str] | None = None,
        max_shakemap_events: int = _DEFAULT_MAX_SHAKEMAP_EVENTS,
    ):
        """Initialise an FDSN backend instance.

        Args:
            start: Inclusive start of the event window, as a string
                parsed with `fmt`.
            end: Inclusive end of the event window.
            variables: List of FDSN network keys to query (`["USGS"]`,
                `["USGS", "EMSC"]`). For this backend `variables`
                names the seismic *networks*, not data variables (see
                the package docstring). An empty list defaults to
                `["USGS"]`.
            lat_lim: `[lat_min, lat_max]` bounding-box latitudes in
                degrees, both in `[-90, 90]`.
            lon_lim: `[lon_min, lon_max]` bounding-box longitudes in
                degrees, both in `[-180, 180]`.
            temporal_resolution: FDSN does not chunk by day/month — the
                whole `[start, end]` window is one query — so this is
                the sentinel `"all"`, not a pandas frequency alias.
            path: Output directory for the per-network vector files.
                Created by the parent class if absent.
            fmt: `strptime` format for `start` / `end`.
            min_magnitude: Lower magnitude bound. `None` (the default)
                falls back per network to that provider's
                `default_min_magnitude` catalog value (USGS / EMSC /
                EarthScope / ISC = 4.5, INGV = 2.0, GeoNet = 3.0), so
                each regional network keeps a sensible floor. Pass an
                explicit number to override every network with one
                bound.
            max_magnitude: Upper magnitude bound, or `None`.
            min_depth: Lower depth bound in kilometres, or `None`.
            max_depth: Upper depth bound in kilometres, or `None`.
            magnitude_type: Restrict to a magnitude type (e.g.
                `"Mw"`), or `None` for any.
            event_type: Restrict to an event type (e.g.
                `"earthquake"`, `"volcanic eruption"`), or `None`.
            orderby: Result ordering — `"time"`, `"time-asc"`,
                `"magnitude"`, or `"magnitude-asc"`.
            limit: Maximum number of events **per network**, or `None` for no
                cap. Unlike the total-row `limit=` the tabular backends take,
                this is pushed into the FDSN query itself, so a capped request
                never transfers the events past the cap; with several networks
                the totals add up rather than being one overall ceiling.
                Rejected if zero or negative.
            earthscope_token: Optional EarthScope access token; falls
                back to `EARTHSCOPE_TOKEN` / `~/.earthscope_token`.
                Used only for a provider that requires a token.
            file_format: Output vector format — `"gpkg"` (default,
                GeoPackage) or `"geojson"`.
            with_shakemap: Also fetch each event's gridded ShakeMap and
                write it as a GeoTIFF under `path/shakemap/<event>/`.
                **USGS only** — ShakeMap is a ComCat product, not part
                of the FDSN event standard, so a non-USGS network in the
                same request contributes events but no rasters. Costs
                one extra ComCat request plus a multi-megabyte archive per
                event, so bound the event count with `limit=` before
                turning it on for a busy window; beyond
                `max_shakemap_events` the side-output stops and says how
                many events it skipped. It does not change
                `OUTPUT_KIND` — `download()` still returns the event
                FeatureCollection, and the rasters are a side effect.
            max_shakemap_events: Ceiling on how many events one call
                will fetch a ShakeMap for, guarding against a broad
                query quietly pulling gigabytes (each event is a
                separate request plus a multi-megabyte archive). Events past
                the ceiling are deferred with a warning naming the
                count — never silently. The ceiling counts *fetches*,
                not events: an event already satisfied on disk costs no
                budget, and an id that can never be fetched at all is
                dropped before counting. So re-running the same request
                takes the next batch, and repeating it eventually walks
                the whole list. The one exception is an event that fails
                on every attempt: a failure is not cached, so it is
                retried each run and keeps its place in the queue.
                Within a run the events kept are the first
                `max_shakemap_events` in the order the networks returned
                them, which `orderby=` controls (`"magnitude"` puts the
                largest first, the usual intent when capping). Raise the
                ceiling deliberately for a large job, or narrow the
                query with `limit=` / `min_magnitude=`.
            shakemap_layers: Which ShakeMap grids to write when
                `with_shakemap` is on. `None` (the default) writes
                `["mmi_mean"]` — macroseismic intensity, the headline
                shaking field. The archive carries fourteen grids
                (`mmi`, `pga`, `pgv`, `psa0p3`, `psa0p6`, `psa1p0`,
                `psa3p0`, each `_mean` and `_std`); all are reachable,
                none but the default are written unless asked for.
                Ignored when `with_shakemap` is `False`.

        Raises:
            ValueError: If `file_format` is not a supported vector
                format, if `limit` is zero or negative, or if
                `shakemap_layers` names a grid the archive does not
                carry.
            TypeError: If `variables` is a mapping — for this backend it
                selects seismic networks, not data variables.
        """
        self._min_magnitude = min_magnitude
        self._max_magnitude = max_magnitude
        self._min_depth = min_depth
        self._max_depth = max_depth
        self._magnitude_type = magnitude_type
        self._event_type = event_type
        self._orderby = orderby
        # Not `self._limit`: the base class owns that name for the
        # client-side total cap, and a provider storing its own meaning there
        # is how usgs_water silently lost its server-side limit. Validated
        # here so a zero/negative cap is refused before it reaches the FDSN
        # query, where it would be a server-side argument whose meaning varies
        # by provider rather than an obvious client-side bug.
        self._request_limit = self.check_limit(limit)
        self._earthscope_token_arg = earthscope_token
        self._earthscope_token: str | None = None
        if file_format not in _DRIVERS:
            raise ValueError(
                f"file_format must be one of {sorted(_DRIVERS)}, got {file_format!r}."
            )
        self._file_format: FileFormat = file_format
        self._with_shakemap = bool(with_shakemap)
        # Validated even when the flag is off, so a typo'd layer name is a
        # construction-time error rather than a surprise the day someone
        # turns the flag on.
        self._shakemap_layers = _helpers.normalize_layers(shakemap_layers)
        # Validated inline rather than through `check_limit`, which is typed
        # for an optional cap: this one is always an int, and keeping it that
        # way lets the ceiling comparison stay a plain `>`.
        if not isinstance(max_shakemap_events, int) or isinstance(
            max_shakemap_events, bool
        ):
            raise TypeError(
                "max_shakemap_events must be an integer, got "
                f"{type(max_shakemap_events).__name__}."
            )
        if max_shakemap_events <= 0:
            raise ValueError(
                "max_shakemap_events must be a positive integer, got "
                f"{max_shakemap_events!r}."
            )
        self._max_shakemap_events: int = max_shakemap_events
        self._http: HttpClient | None = None
        if isinstance(variables, dict):
            raise TypeError(
                "FDSN `variables` must be a list of network keys (e.g. "
                "['USGS', 'EMSC']), not a mapping. For this backend "
                "`variables` selects seismic networks, not data variables; "
                "query filters are explicit FDSN(...) keyword arguments."
            )
        self._catalog = Catalog()
        super().__init__(
            start=start,
            end=end,
            variables=list(variables) or list(_DEFAULT_PROVIDERS),
            temporal_resolution=temporal_resolution,
            lat_lim=lat_lim,
            lon_lim=lon_lim,
            fmt=fmt,
            path=path,
        )

    def _initialize(self):
        """Resolve the optional EarthScope token; build no global client.

        FDSN clients are per-network and built lazily in :meth:`_fetch`,
        so there is no shared client to bind to `self.client`. The only
        global state is the optional EarthScope token, resolved here so
        a missing-token situation surfaces once up front rather than per
        query.

        Returns:
            None: No per-instance client object.
        """
        self._earthscope_token = resolve_earthscope_token(self._earthscope_token_arg)
        return None

    def _check_input_dates(
        self,
        start: str,
        end: str,
        temporal_resolution: str,
        fmt: str,
    ) -> TemporalExtent:
        """Parse the `[start, end]` window into a :class:`TemporalExtent`.

        FDSN issues a single `get_events` call spanning the whole
        window, so there is no per-date loop. The resolution is kept as
        the FDSN sentinel `"all"` (not a real pandas frequency alias —
        it means "single unchunked window, no per-date iteration") and
        `dates` collapses to the two endpoints.

        Args:
            start: Inclusive start date string.
            end: Inclusive end date string.
            temporal_resolution: Ignored beyond being recorded as the
                resolution label; FDSN always queries the full window.
            fmt: `strptime` format tried first for a string `start` /
                `end`; a non-matching string falls back to an ISO-8601
                parse, and a `datetime` / `date` ignores it.

        Returns:
            TemporalExtent: Frozen model with the parsed endpoints.

        Raises:
            ValueError: If `start` parses to a date later than `end`.
        """
        return self._whole_window_extent(start, end, fmt=fmt, resolution="all")

    def _search(self) -> list[RemoteProduct]:
        """One :class:`RemoteProduct` per requested network.

        Resolves each network key in `self.vars` against the bundled
        provider catalog (raising with a did-you-mean hint on an
        unknown key) and records the resolved `fdsn_id` / `needs_token`
        on the product metadata. No network call is made here.

        Returns:
            list[RemoteProduct]: One product per network key, in
                request order; `id` is the network key and `metadata`
                carries `fdsn_id` and `needs_token`.

        Raises:
            ValueError: If a key in `self.vars` is not a registered
                provider.
        """
        products: list[RemoteProduct] = []
        # De-duplicated, order preserved: a repeated key would otherwise issue
        # the same query twice and union the same events into the result.
        for key in dict.fromkeys(self.vars):
            provider: Provider = self._catalog.get_provider(key)
            products.append(
                RemoteProduct(
                    id=key,
                    metadata={
                        "fdsn_id": provider.fdsn_id,
                        "needs_token": provider.needs_token,
                        "default_min_magnitude": provider.default_min_magnitude,
                    },
                )
            )
        return products

    def _fetch(self, products: list[RemoteProduct]) -> list[FeatureCollection]:
        """Query every network and map each result to a FeatureCollection.

        Widens the inherited `-> list[Path]` contract: a vector backend
        returns in-memory :class:`FeatureCollection`s, not file paths.
        A network whose query matches nothing
        (`FDSNNoDataException`, HTTP 204) yields an empty
        FeatureCollection — an empty result is a legitimate answer for a
        quiet region/time.

        A network whose query *errors* (timeout, HTTP 5xx, service
        unavailable) is logged and skipped rather than aborting the
        whole request — one flaky network does not lose the events
        already fetched from healthy ones (mirrors the ECMWF/CMEMS
        "one bad item does not kill the batch" policy). The skipped
        network contributes an empty FeatureCollection so the returned
        list stays positionally aligned with `products`. Only a
        **total** failure (every network errored) raises.

        The partial-failure policy is whatever `download(errors=...)`
        recorded on the instance: `"warn"` (the default) logs each failed
        network and continues, `"raise"` propagates the first failure, and
        `"ignore"` continues silently. An all-failed batch raises
        regardless of the policy.

        Args:
            products: The list returned by :meth:`_search`.

        Returns:
            list[FeatureCollection]: One collection per product, in the
                same order; empty collections for no-data or failed
                networks.

        Raises:
            RuntimeError: When **every** network's query failed, so a
                caller cannot silently process nothing. The message
                aggregates the failed networks and their exception
                types; the per-network errors are logged at ERROR.
        """
        collections, failed = self._run_items(
            products,
            self._query_one,
            errors=self._errors,
            label="provider query",
            describe=lambda product: repr(product.id),
            on_failure=lambda _product, _exc: events.empty_fc(),
        )

        if failed and len(failed) == len(products):
            summary = ", ".join(
                f"{pid} ({type(exc).__name__}: {exc})" for pid, exc in failed
            )
            raise RuntimeError(
                f"all {len(failed)} FDSN provider query(ies) failed: "
                f"{summary}. See the per-provider ERROR logs above."
            )
        if failed:
            summary = ", ".join(f"{pid} ({type(exc).__name__})" for pid, exc in failed)
            logger.warning(
                f"{len(failed)} of {len(products)} FDSN provider query(ies) "
                f"failed and were skipped: {summary}"
            )
        return collections

    def _query_one(self, product: RemoteProduct) -> FeatureCollection:
        """Run one network's `get_events` and map it to a FeatureCollection.

        Args:
            product: One :class:`RemoteProduct` from :meth:`_search`;
                `product.id` is the network key and
                `product.metadata["fdsn_id"]` selects the obspy client.

        Returns:
            FeatureCollection: Events for this network (empty on a
                no-data response).
        """
        from obspy import UTCDateTime
        from obspy.clients.fdsn import Client
        from obspy.clients.fdsn.header import FDSNNoDataException

        from earthlens.core import __version__

        provider_key = product.id
        fdsn_id = product.metadata["fdsn_id"]
        needs_token = product.metadata.get("needs_token", False)

        # An explicit min_magnitude overrides every network; otherwise fall
        # back to this provider's catalog default so regional networks (INGV,
        # GeoNet) keep their lower floor instead of the global 4.5.
        min_magnitude = (
            self._min_magnitude
            if self._min_magnitude is not None
            else product.metadata.get("default_min_magnitude")
        )

        client_kwargs: dict[str, object] = {"user_agent": f"earthlens/{__version__}"}
        if needs_token and self._earthscope_token:
            # obspy's only token slot is `eida_token`. The bundled networks are
            # all public (needs_token=False), so this branch is opt-in: a
            # maintainer who adds a token-gated network must confirm that
            # network accepts an EIDA-style token before relying on it.
            client_kwargs["eida_token"] = self._earthscope_token
        client = Client(fdsn_id, **client_kwargs)

        logger.info(
            f"Querying FDSN provider {provider_key!r} ({fdsn_id}) for events "
            f"{self.time.start_date}..{self.time.end_date} "
            f"min_magnitude={min_magnitude}"
        )
        try:
            catalog = client.get_events(
                starttime=UTCDateTime(self.time.start_date),
                endtime=UTCDateTime(self.time.end_date),
                minlatitude=self.space.south,
                maxlatitude=self.space.north,
                minlongitude=self.space.west,
                maxlongitude=self.space.east,
                minmagnitude=min_magnitude,
                maxmagnitude=self._max_magnitude,
                mindepth=self._min_depth,
                maxdepth=self._max_depth,
                magnitudetype=self._magnitude_type,
                eventtype=self._event_type,
                orderby=self._orderby,
                limit=self._request_limit,
            )
        except FDSNNoDataException:
            logger.info(
                f"FDSN provider {provider_key!r} returned no events for the "
                "requested window — empty result."
            )
            return events.empty_fc()
        return events.catalog_to_fc(catalog, provider_key)

    def download(
        self,
        progress_bar: bool = True,
        errors: str = "warn",
        limit: int | None = None,
        force: bool = False,
    ) -> FeatureCollection:
        """Query every requested network and return the unioned events.

        Each network in `self.vars` is queried once; its events are
        written to one vector file under `path` (named after the
        network), and the per-network results are concatenated into the
        single :class:`FeatureCollection` returned. A network that
        errors is logged and skipped (its events are simply absent from
        the union) rather than aborting the whole request; only a total
        failure — every network errored — raises. An all-empty result
        (every network matched nothing) returns a schema-correct empty
        FeatureCollection and writes nothing.

        When the instance was built with `with_shakemap=True`, each USGS
        event additionally gets its ShakeMap grids written as GeoTIFFs
        under `path/shakemap/<event>/`. Those rasters are a side effect
        only — the return value stays the event FeatureCollection. An
        event whose ShakeMap cannot be fetched is logged and skipped
        under the same `errors=` policy as a failed network, so one
        missing grid never costs the event table.

        Args:
            progress_bar: Whether to show a progress bar. obspy's
                `get_events` has none, so this only governs the ShakeMap
                loop, which is the long part of a `with_shakemap=True`
                call; a plain event query ignores it.
            errors: Partial-failure policy, applied to **both** the
                per-network query loop and the per-event ShakeMap loop.
                `"warn"` (the default) logs each failure and continues,
                `"ignore"` continues silently, and `"raise"` propagates
                the first failure. Note what `"raise"` costs on a
                `with_shakemap=True` call: the events have already been
                fetched and written by then, but raising out of
                `download()` means they are not *returned*, so a single
                missing ShakeMap loses the whole in-memory event table.
                Leave it at `"warn"` unless a missing raster should
                genuinely abort the request.
            force: Refetch a ShakeMap whose GeoTIFFs are already on
                disk instead of reusing them. A `download()` argument,
                not a constructor one — `EarthLens(...).download(force=True)`,
                not `EarthLens(force=True, ...)`. The rasters are written
                atomically, so a present file is a finished one and the
                default reuse is safe; this is the escape hatch for a
                file damaged after the fact, or for picking up a
                revised ShakeMap for an event USGS has since updated.
            limit: Maximum events **per network**, overriding the constructor's
                `limit=` for this call. Same meaning as that one — pushed into
                the FDSN query itself, so a per-network server-side cap rather
                than a total across networks — and accepted here so it can be
                passed through `EarthLens(...).download(limit=...)` like the
                other bounded backends. `None` (the default) keeps whatever the
                constructor set — note that a value passed here is **sticky**:
                it replaces the constructor's for this and every later call on
                the same instance, as `force=` and `errors=` do not.

        Returns:
            FeatureCollection: The row-wise union of every requested
                network's events, CRS `EPSG:4326`. Empty (schema-only)
                when no network matched anything.

        Raises:
            RuntimeError: If **every** requested network's query failed
                (propagated from :meth:`_fetch`). A partial failure of
                the *network* loop does not raise — the healthy
                networks' events are returned. The ShakeMap loop is
                governed by the same `errors=` policy, so under the
                default `"warn"` a failed raster does not raise either;
                under `errors="raise"` it does, and because the raise
                escapes `download()` the event table is not returned
                even though its files are already on disk.
        """
        if limit is not None:
            self._request_limit = self.check_limit(limit)
        self._errors = self.check_errors_policy(errors)
        self._force = force
        products = self._search()
        collections = self._fetch(products) if products else []

        written: list[Path] = []
        for product, collection in zip(products, collections):
            if len(collection):
                written.append(self._write(product.id, collection))

        rasters: list[Path] = []
        if self._with_shakemap:
            # Before the concat: the per-network split is what says which
            # events came from USGS, and the union has thrown that away.
            try:
                rasters = self._download_shakemaps(
                    products, collections, progress_bar=progress_bar
                )
            finally:
                self._close_client()

        combined = events.concat_fcs(collections)
        # `concat_fcs` has copied every network's events, so the per-network
        # copies are dead weight from here on. This does not lower the *peak* —
        # that was reached inside the concat, with both sets live — but it stops
        # them being held through the summary, the return, and however long the
        # caller keeps the result.
        collections.clear()
        if written:
            # "available" rather than "written": a re-run reuses rasters that
            # were already on disk, and calling those written would overstate
            # what this call did.
            raster_note = (
                f" plus {len(rasters)} ShakeMap raster(s) available"
                if self._with_shakemap
                else ""
            )
            logger.info(
                f"FDSN download summary: {len(combined)} events across "
                f"{len(written)} file(s){raster_note} written to {self.root_dir}"
            )
        else:
            logger.warning(
                "FDSN download summary: no events matched the request, nothing written"
            )
        return combined

    def _client(self) -> HttpClient:
        """Return this instance's pooled ComCat client, built on first use.

        Only the ShakeMap path needs HTTP — the event query itself goes
        through obspy — so the client is built lazily and a request
        without `with_shakemap=True` never opens a session at all.

        Returns:
            HttpClient: The same instance on every later call.
        """
        if self._http is None:
            # ComCat is a single origin fetched once per event, so a dropped
            # connection is a normal event rather than a signal to give up on
            # the whole batch; retrying transport errors matches flodis/hanze.
            # The default user-agent is already `earthlens/{version}`.
            self._http = HttpClient(
                timeout=_COMCAT_TIMEOUT,
                min_interval=_COMCAT_MIN_INTERVAL,
                # ChunkedEncodingError is the one a multi-megabyte body actually hits:
                # the connection survives the handshake and dies mid-stream, which
                # is neither a ConnectionError nor a Timeout.
                retry_on_exceptions=(
                    requests.ConnectionError,
                    requests.Timeout,
                    requests.exceptions.ChunkedEncodingError,
                ),
            )
        return self._http

    def _close_client(self) -> None:
        """Release the pooled ComCat client, if one was ever built.

        `HttpClient` exposes no `close()` of its own — only `HttpRangeFile`
        does — so the underlying `requests.Session` is closed directly. A
        session left open holds its connection pool for the lifetime of the
        backend instance, which matters here because the ShakeMap client is
        built per call and used for a bounded burst.
        """
        if self._http is not None:
            # `session` is annotated `requests.Session`, but `HttpClient`
            # accepts any session-like object and `RequestsGet` — the
            # per-call adapter earthlens.testing swaps in as the default
            # transport — has no `close()`. Closing unconditionally therefore
            # fails against a perfectly valid transport, which is why this is
            # a probe rather than a direct call.
            closer = getattr(self._http.session, "close", None)
            if callable(closer):
                closer()
            self._http = None

    def _download_shakemaps(
        self,
        products: list[RemoteProduct],
        collections: list[FeatureCollection],
        progress_bar: bool = True,
    ) -> list[Path]:
        """Write the ShakeMap side-output for every USGS event fetched.

        ShakeMap is a USGS ComCat product with no counterpart in the FDSN
        event standard, so a non-USGS network that returned events is
        logged once and skipped rather than silently producing nothing.

        Args:
            products: The products from :meth:`_search`.
            collections: The per-product FeatureCollections from
                :meth:`_fetch`, still split by network.
            progress_bar: Whether each archive fetch shows a progress
                bar. This is the long part of a `with_shakemap=True`
                call, so it is the only place the flag does anything.

        Returns:
            list[Path]: Every GeoTIFF available for the requested
                events — those written by this call plus any reused
                from a previous run.
        """
        event_ids: list[str] = []
        for product, collection in zip(products, collections):
            if product.metadata.get("fdsn_id") != _helpers.COMCAT_PROVIDER:
                if len(collection):
                    logger.warning(
                        f"with_shakemap=True but {product.id!r} is not "
                        f"{_helpers.COMCAT_PROVIDER}: ShakeMap is a USGS ComCat "
                        "product, so this network contributes events but no "
                        "rasters."
                    )
                continue
            event_ids.extend(str(value) for value in collection["event_id"])
        # Two networks can report the same ComCat event, and a catalog can
        # repeat one; de-duplicated so the ceiling counts each event once.
        event_ids = list(dict.fromkeys(event_ids))

        if not event_ids:
            return []

        # Drop what can never succeed before anything is counted. An id that
        # yields no ComCat id, or one whose directory fails the containment
        # assertion, is not work waiting to be done — left in, it would sit at
        # the head of the queue spending budget on every run and starve the
        # events behind it.
        event_ids = [event_id for event_id in event_ids if self._is_fetchable(event_id)]
        if not event_ids:
            return []

        # The ceiling bounds *work*, not events. An event already satisfied on
        # disk costs nothing, so spending budget on it would stall a re-run at
        # the same place forever instead of letting it advance through the list.
        pending = [event_id for event_id in event_ids if not self._is_cached(event_id)]
        if len(pending) > self._max_shakemap_events:
            deferred = set(pending[self._max_shakemap_events :])
            logger.warning(
                f"with_shakemap=True needs {len(pending)} fetches, over the "
                f"max_shakemap_events={self._max_shakemap_events} ceiling: taking "
                f"the first {self._max_shakemap_events} and deferring "
                f"{len(deferred)}. Each fetch costs a request plus a "
                "multi-megabyte archive. Re-run to take the next batch, raise "
                "max_shakemap_events= to take them all at once, or narrow the "
                "query with limit= / min_magnitude=."
            )
            event_ids = [event_id for event_id in event_ids if event_id not in deferred]

        fetching = sum(1 for event_id in event_ids if not self._is_cached(event_id))
        logger.info(
            f"ShakeMap {list(self._shakemap_layers)}: {fetching} event(s) to fetch, "
            f"{len(event_ids) - fetching} already on disk"
        )
        # A per-archive bar is useful for a single fetch and unreadable for
        # fifty, so a batch reports through the log line above instead.
        per_archive_bar = progress_bar and len(event_ids) == 1
        results, _failed = self._run_items(
            event_ids,
            lambda event_id: self._shakemap_for_event(
                event_id, progress_bar=per_archive_bar
            ),
            errors=self._errors,
            label="ShakeMap",
            describe=repr,
            on_failure=lambda _event_id, _exc: [],
        )
        written = [path for paths in results for path in paths]
        shakemap_root = self.root_dir / _SHAKEMAP_DIR
        # An empty `shakemap/` reads as "asked for, produced nothing" only if it
        # is there at all; removing it keeps a fruitless run from looking like a
        # partial success.
        with suppress(OSError):
            if shakemap_root.is_dir() and not any(shakemap_root.iterdir()):
                shakemap_root.rmdir()
        return written

    def _record_manifest(
        self,
        dest_dir: Path,
        produced: list[str],
        product_version: str | None = None,
    ) -> None:
        """Record an event's outcome, warning rather than failing if it cannot.

        The manifest is a cache, not the deliverable: a raster already written
        must not be lost because its bookkeeping could not be saved. A failure
        is surfaced, though, because the only symptom otherwise is a re-run
        that silently refetches everything.

        Args:
            dest_dir: The event's output directory.
            produced: The layers the archive actually carried.
            product_version: The ShakeMap product's `updateTime`, if known.

        Returns:
            None: The manifest is written as a side effect; a failure is
                logged rather than raised.
        """
        try:
            _helpers.write_manifest(
                dest_dir,
                self._shakemap_layers,
                produced,
                checked=time.time(),
                product_version=product_version,
            )
        except OSError as error:
            logger.warning(
                f"could not record the ShakeMap manifest in {dest_dir}: {error}. "
                "The rasters are written, but a re-run will fetch them again."
            )

    def _event_dir(self, comcat_id: str) -> Path | None:
        """Resolve an event's output directory, refusing one that escapes.

        Args:
            comcat_id: The event's ComCat id.

        Returns:
            Path | None: The directory, or `None` when it does not resolve
                inside the ShakeMap root.
        """
        shakemap_root = self.root_dir / _SHAKEMAP_DIR
        dest_dir = shakemap_root / comcat_id
        # Defence in depth. `parse_comcat_id` already refuses an id that could
        # traverse, but the id comes from an upstream server and this directory
        # is deleted from, so the containment is asserted rather than assumed.
        if not dest_dir.resolve().is_relative_to(shakemap_root.resolve()):
            logger.warning(
                f"refusing ComCat id {comcat_id!r}: it does not resolve inside "
                f"{shakemap_root} — skipping its ShakeMap."
            )
            return None
        return dest_dir

    def _cached_rasters(self, dest_dir: Path) -> list[Path] | None:
        """Decide whether an event can be served from disk, without logging.

        Split from :meth:`_reuse_existing` so the fan-out ceiling can ask the
        same question about an event it may not process, without emitting a
        skip line for work it never started.

        Args:
            dest_dir: The event's output directory.

        Returns:
            list[Path] | None: The rasters to reuse — empty for an event known
                to publish none — or `None` when the event must be fetched.
        """
        if self._force:
            return None
        manifest = _helpers.read_manifest(dest_dir)
        if manifest is None:
            return None
        # A previous call may have asked for fewer layers than this one. Reuse
        # only when the earlier request covered everything now wanted.
        previously = set(manifest.get("requested", []))
        if not set(self._shakemap_layers).issubset(previously):
            return None
        recorded = set(manifest.get("produced", []))
        produced = [layer for layer in self._shakemap_layers if layer in recorded]
        missing = [layer for layer in self._shakemap_layers if layer not in recorded]
        if missing and self._record_is_stale(manifest):
            # Any requested layer the last run did not produce is a negative
            # result, and negative results age out — whether or not some *other*
            # requested layer happens to be on disk. Checking this only when the
            # intersection was empty meant a partially-published event cached its
            # missing grids permanently, which is exactly the case the manifest
            # was introduced for.
            return None
        cached = [dest_dir / f"{layer}.tif" for layer in produced]
        if not all(self._is_complete(path) for path in cached):
            return None
        if missing:
            logger.warning(
                f"ShakeMap layer(s) {missing} are not available for this event and "
                f"were last checked recently; returning {len(cached)} of "
                f"{len(self._shakemap_layers)} requested. Pass force=True to "
                "re-check now."
            )
        return cached

    @staticmethod
    def _record_is_stale(manifest: dict[str, object]) -> bool:
        """Report whether a manifest's negative half should be re-checked.

        Args:
            manifest: The parsed manifest.

        Returns:
            bool: `True` when the record is older than the negative-cache
                window, or carries a timestamp that cannot be trusted — a
                future one, or a non-finite one. An unusable clock reading
                fails towards refetching rather than towards caching an
                answer forever.
        """
        stamp = manifest.get("checked", 0.0)
        if not isinstance(stamp, (int, float)) or isinstance(stamp, bool):
            return True
        age = time.time() - float(stamp)
        # Written as a range test so a negative age (a future stamp) and a NaN
        # age (every comparison False) both come out stale.
        return not (0.0 <= age <= _NEGATIVE_CACHE_SECONDS)

    def _is_fetchable(self, event_id: str) -> bool:
        """Report whether an event could ever yield a ShakeMap.

        Separated from :meth:`_is_cached` because the two answer different
        questions: this one asks whether the event is *addressable* at all,
        and an event that is not can never become cached, so counting it as
        pending work would let it block the queue indefinitely.

        Args:
            event_id: The event's `event_id` column value.

        Returns:
            bool: `False` when no ComCat id can be parsed, or the resolved
                directory would escape the ShakeMap root.
        """
        comcat_id = _helpers.parse_comcat_id(event_id)
        if comcat_id is None:
            logger.warning(
                f"cannot resolve a ComCat id from event_id {event_id!r} — "
                "skipping its ShakeMap."
            )
            return False
        return self._event_dir(comcat_id) is not None

    def _is_cached(self, event_id: str) -> bool:
        """Report whether an event needs no work this run.

        Args:
            event_id: The event's `event_id` column value.

        Returns:
            bool: `True` when the event is already satisfied on disk, so the
                fan-out ceiling should not spend budget on it.
        """
        comcat_id = _helpers.parse_comcat_id(event_id)
        if comcat_id is None:
            return False
        dest_dir = self._event_dir(comcat_id)
        if dest_dir is None:
            return False
        return self._cached_rasters(dest_dir) is not None

    def _reuse_existing(self, dest_dir: Path, comcat_id: str) -> list[Path] | None:
        """Return an event's cached rasters, or `None` to fetch it again.

        Reuse is decided from the event's manifest rather than from the
        requested layer names, because an archive need not carry every
        layer that was asked for. Without the manifest, an event whose
        archive permanently lacks one requested grid could never satisfy
        an "all requested rasters present" check and would re-download
        its multi-megabyte archive on every single run.

        Args:
            dest_dir: The event's output directory.
            comcat_id: The event's ComCat id, for the log line.

        Returns:
            list[Path] | None: The rasters already on disk for this
                request — possibly empty, when the event publishes no
                ShakeMap at all — or `None` when the event must be
                fetched.
        """
        cached = self._cached_rasters(dest_dir)
        if cached is None:
            return None
        if cached:
            logger.info(f"ShakeMap for {comcat_id} already on disk — skipping.")
        else:
            logger.info(
                f"as of the last run, event {comcat_id} published no ShakeMap "
                "raster for the requested layer(s) — skipping."
            )
        return cached

    def _shakemap_for_event(
        self, event_id: str, progress_bar: bool = True
    ) -> list[Path]:
        """Fetch, unpack, and convert one event's requested ShakeMap grids.

        Skips the network when the event's manifest says this run's
        layers are already accounted for — either their GeoTIFFs are on
        disk, or the archive was checked recently and carries none of
        them. The skip is decided from what a previous run *recorded*,
        not from whether every requested file exists, because an archive
        need not carry every layer that was asked for.

        Everything the fetch and the conversion touch stays inside a
        staging directory that is removed wholesale, so no intermediate
        — the archive, the `.flt` / `.hdr` pairs, or the `.prj` GDAL
        writes when the CRS is assigned — reaches the output folder.

        Args:
            event_id: The event's `event_id` column value, a QuakeML
                resource identifier carrying the ComCat id.
            progress_bar: Whether the archive fetch shows a progress bar.

        Returns:
            list[Path]: The GeoTIFFs written for this event; empty when
                the identifier carries no ComCat id, the event has no
                ShakeMap product, or the archive lacked every requested
                grid.
        """
        comcat_id = _helpers.parse_comcat_id(event_id)
        if comcat_id is None:
            logger.warning(
                f"cannot resolve a ComCat id from event_id {event_id!r} — "
                "skipping its ShakeMap."
            )
            return []

        dest_dir = self._event_dir(comcat_id)
        if dest_dir is None:
            return []

        reused = self._reuse_existing(dest_dir, comcat_id)
        if reused is not None:
            return reused

        detail = self._client().get_json(_helpers.detail_url(comcat_id))
        url = _helpers.shakemap_raster_url(detail)
        if url is None:
            logger.info(
                f"event {comcat_id} publishes no ShakeMap raster — skipping it."
            )
            # Recorded so a re-run does not re-request this event's detail
            # document only to reach the same conclusion.
            self._record_manifest(dest_dir, [])
            return []

        # Everything the fetch and the conversion touch is confined to a staging
        # directory that is removed wholesale, rather than cleaned up by globbing
        # the user's output directory for suffixes. That keeps GDAL's own
        # by-products out of the result: opening the `EHdr` grid read-write to
        # assign its CRS makes the driver drop a `<layer>.prj` beside the `.flt`,
        # which a suffix-based sweep would leave behind in the output folder.
        staging = dest_dir / f"{_STAGING_PREFIX}-{os.getpid()}"
        archive = staging / "raster.zip"
        written: list[Path] = []
        try:
            staging.mkdir(parents=True, exist_ok=True)
            # `expect_magic` keeps an HTML error page served with a 200 from
            # landing as a .zip and failing later as a confusing bad-archive.
            self._client().download(
                url, archive, expect_magic=b"PK", progress=progress_bar
            )
            extracted = _helpers.extract_layers(archive, self._shakemap_layers, staging)
            for layer, flt_path in extracted.items():
                written.append(
                    _helpers.flt_to_geotiff(flt_path, dest_dir / f"{layer}.tif")
                )
            # Only after every conversion succeeded: a manifest written on a
            # partial failure would cache an incomplete result as final.
            self._record_manifest(
                dest_dir,
                list(extracted),
                product_version=_helpers.shakemap_product_version(detail),
            )
        finally:
            shutil.rmtree(staging, ignore_errors=True)
            if staging.exists():
                # Reported, not swallowed. On Windows this means something still
                # holds a handle on an extracted grid; silently leaving it would
                # accumulate one scratch directory per failed event inside the
                # user's output.
                logger.warning(
                    f"could not remove the ShakeMap staging directory {staging} — "
                    "it is left behind; remove it by hand if it persists."
                )
            # An event whose archive was unusable leaves no rasters, and an
            # empty directory named after it reads like a successful fetch.
            with suppress(OSError):
                if dest_dir.is_dir():
                    remaining = list(dest_dir.iterdir())
                    if not remaining:
                        dest_dir.rmdir()
                    elif all(
                        item.name.startswith(_STAGING_PREFIX) for item in remaining
                    ):
                        # Another process's scratch space, or one left by a run
                        # that died. Not ours to delete, but silence would let it
                        # accumulate unnoticed and keep this directory alive.
                        logger.warning(
                            f"{dest_dir} holds only orphaned scratch "
                            f"director(ies) {[item.name for item in remaining]} "
                            "from an interrupted run; remove them by hand."
                        )
        return written

    def _write(self, provider_key: str, collection: FeatureCollection) -> Path:
        """Write one network's events to a vector file under `root_dir`.

        Args:
            provider_key: The network key, used as the filename stem.
            collection: The network's events.

        Returns:
            Path: Absolute path of the file written.
        """
        driver, ext = _DRIVERS[self._file_format]
        out_path = self.root_dir / f"{provider_key.lower()}.{ext}"
        collection.to_file(str(out_path), driver=driver)
        return out_path

__init__(start, end, variables, lat_lim, lon_lim, temporal_resolution='all', path=None, fmt='%Y-%m-%d', min_magnitude=None, max_magnitude=None, min_depth=None, max_depth=None, magnitude_type=None, event_type=None, orderby='time', limit=None, earthscope_token=None, file_format='gpkg', with_shakemap=False, shakemap_layers=None, max_shakemap_events=_DEFAULT_MAX_SHAKEMAP_EVENTS) #

Initialise an FDSN backend instance.

Parameters:

Name Type Description Default
start str

Inclusive start of the event window, as a string parsed with fmt.

required
end str

Inclusive end of the event window.

required
variables list[str]

List of FDSN network keys to query (["USGS"], ["USGS", "EMSC"]). For this backend variables names the seismic networks, not data variables (see the package docstring). An empty list defaults to ["USGS"].

required
lat_lim list[float]

[lat_min, lat_max] bounding-box latitudes in degrees, both in [-90, 90].

required
lon_lim list[float]

[lon_min, lon_max] bounding-box longitudes in degrees, both in [-180, 180].

required
temporal_resolution str

FDSN does not chunk by day/month — the whole [start, end] window is one query — so this is the sentinel "all", not a pandas frequency alias.

'all'
path Path | str | None

Output directory for the per-network vector files. Created by the parent class if absent.

None
fmt str

strptime format for start / end.

'%Y-%m-%d'
min_magnitude float | None

Lower magnitude bound. None (the default) falls back per network to that provider's default_min_magnitude catalog value (USGS / EMSC / EarthScope / ISC = 4.5, INGV = 2.0, GeoNet = 3.0), so each regional network keeps a sensible floor. Pass an explicit number to override every network with one bound.

None
max_magnitude float | None

Upper magnitude bound, or None.

None
min_depth float | None

Lower depth bound in kilometres, or None.

None
max_depth float | None

Upper depth bound in kilometres, or None.

None
magnitude_type str | None

Restrict to a magnitude type (e.g. "Mw"), or None for any.

None
event_type str | None

Restrict to an event type (e.g. "earthquake", "volcanic eruption"), or None.

None
orderby str

Result ordering — "time", "time-asc", "magnitude", or "magnitude-asc".

'time'
limit int | None

Maximum number of events per network, or None for no cap. Unlike the total-row limit= the tabular backends take, this is pushed into the FDSN query itself, so a capped request never transfers the events past the cap; with several networks the totals add up rather than being one overall ceiling. Rejected if zero or negative.

None
earthscope_token str | None

Optional EarthScope access token; falls back to EARTHSCOPE_TOKEN / ~/.earthscope_token. Used only for a provider that requires a token.

None
file_format FileFormat

Output vector format — "gpkg" (default, GeoPackage) or "geojson".

'gpkg'
with_shakemap bool

Also fetch each event's gridded ShakeMap and write it as a GeoTIFF under path/shakemap/<event>/. USGS only — ShakeMap is a ComCat product, not part of the FDSN event standard, so a non-USGS network in the same request contributes events but no rasters. Costs one extra ComCat request plus a multi-megabyte archive per event, so bound the event count with limit= before turning it on for a busy window; beyond max_shakemap_events the side-output stops and says how many events it skipped. It does not change OUTPUT_KINDdownload() still returns the event FeatureCollection, and the rasters are a side effect.

False
max_shakemap_events int

Ceiling on how many events one call will fetch a ShakeMap for, guarding against a broad query quietly pulling gigabytes (each event is a separate request plus a multi-megabyte archive). Events past the ceiling are deferred with a warning naming the count — never silently. The ceiling counts fetches, not events: an event already satisfied on disk costs no budget, and an id that can never be fetched at all is dropped before counting. So re-running the same request takes the next batch, and repeating it eventually walks the whole list. The one exception is an event that fails on every attempt: a failure is not cached, so it is retried each run and keeps its place in the queue. Within a run the events kept are the first max_shakemap_events in the order the networks returned them, which orderby= controls ("magnitude" puts the largest first, the usual intent when capping). Raise the ceiling deliberately for a large job, or narrow the query with limit= / min_magnitude=.

_DEFAULT_MAX_SHAKEMAP_EVENTS
shakemap_layers list[str] | None

Which ShakeMap grids to write when with_shakemap is on. None (the default) writes ["mmi_mean"] — macroseismic intensity, the headline shaking field. The archive carries fourteen grids (mmi, pga, pgv, psa0p3, psa0p6, psa1p0, psa3p0, each _mean and _std); all are reachable, none but the default are written unless asked for. Ignored when with_shakemap is False.

None

Raises:

Type Description
ValueError

If file_format is not a supported vector format, if limit is zero or negative, or if shakemap_layers names a grid the archive does not carry.

TypeError

If variables is a mapping — for this backend it selects seismic networks, not data variables.

Source code in libs/providers/hazards/src/earthlens/fdsn/backend.py
def __init__(
    self,
    start: str,
    end: str,
    variables: list[str],
    lat_lim: list[float],
    lon_lim: list[float],
    temporal_resolution: str = "all",
    path: Path | str | None = None,
    fmt: str = "%Y-%m-%d",
    min_magnitude: float | None = None,
    max_magnitude: float | None = None,
    min_depth: float | None = None,
    max_depth: float | None = None,
    magnitude_type: str | None = None,
    event_type: str | None = None,
    orderby: str = "time",
    limit: int | None = None,
    earthscope_token: str | None = None,
    file_format: FileFormat = "gpkg",
    with_shakemap: bool = False,
    shakemap_layers: list[str] | None = None,
    max_shakemap_events: int = _DEFAULT_MAX_SHAKEMAP_EVENTS,
):
    """Initialise an FDSN backend instance.

    Args:
        start: Inclusive start of the event window, as a string
            parsed with `fmt`.
        end: Inclusive end of the event window.
        variables: List of FDSN network keys to query (`["USGS"]`,
            `["USGS", "EMSC"]`). For this backend `variables`
            names the seismic *networks*, not data variables (see
            the package docstring). An empty list defaults to
            `["USGS"]`.
        lat_lim: `[lat_min, lat_max]` bounding-box latitudes in
            degrees, both in `[-90, 90]`.
        lon_lim: `[lon_min, lon_max]` bounding-box longitudes in
            degrees, both in `[-180, 180]`.
        temporal_resolution: FDSN does not chunk by day/month — the
            whole `[start, end]` window is one query — so this is
            the sentinel `"all"`, not a pandas frequency alias.
        path: Output directory for the per-network vector files.
            Created by the parent class if absent.
        fmt: `strptime` format for `start` / `end`.
        min_magnitude: Lower magnitude bound. `None` (the default)
            falls back per network to that provider's
            `default_min_magnitude` catalog value (USGS / EMSC /
            EarthScope / ISC = 4.5, INGV = 2.0, GeoNet = 3.0), so
            each regional network keeps a sensible floor. Pass an
            explicit number to override every network with one
            bound.
        max_magnitude: Upper magnitude bound, or `None`.
        min_depth: Lower depth bound in kilometres, or `None`.
        max_depth: Upper depth bound in kilometres, or `None`.
        magnitude_type: Restrict to a magnitude type (e.g.
            `"Mw"`), or `None` for any.
        event_type: Restrict to an event type (e.g.
            `"earthquake"`, `"volcanic eruption"`), or `None`.
        orderby: Result ordering — `"time"`, `"time-asc"`,
            `"magnitude"`, or `"magnitude-asc"`.
        limit: Maximum number of events **per network**, or `None` for no
            cap. Unlike the total-row `limit=` the tabular backends take,
            this is pushed into the FDSN query itself, so a capped request
            never transfers the events past the cap; with several networks
            the totals add up rather than being one overall ceiling.
            Rejected if zero or negative.
        earthscope_token: Optional EarthScope access token; falls
            back to `EARTHSCOPE_TOKEN` / `~/.earthscope_token`.
            Used only for a provider that requires a token.
        file_format: Output vector format — `"gpkg"` (default,
            GeoPackage) or `"geojson"`.
        with_shakemap: Also fetch each event's gridded ShakeMap and
            write it as a GeoTIFF under `path/shakemap/<event>/`.
            **USGS only** — ShakeMap is a ComCat product, not part
            of the FDSN event standard, so a non-USGS network in the
            same request contributes events but no rasters. Costs
            one extra ComCat request plus a multi-megabyte archive per
            event, so bound the event count with `limit=` before
            turning it on for a busy window; beyond
            `max_shakemap_events` the side-output stops and says how
            many events it skipped. It does not change
            `OUTPUT_KIND` — `download()` still returns the event
            FeatureCollection, and the rasters are a side effect.
        max_shakemap_events: Ceiling on how many events one call
            will fetch a ShakeMap for, guarding against a broad
            query quietly pulling gigabytes (each event is a
            separate request plus a multi-megabyte archive). Events past
            the ceiling are deferred with a warning naming the
            count — never silently. The ceiling counts *fetches*,
            not events: an event already satisfied on disk costs no
            budget, and an id that can never be fetched at all is
            dropped before counting. So re-running the same request
            takes the next batch, and repeating it eventually walks
            the whole list. The one exception is an event that fails
            on every attempt: a failure is not cached, so it is
            retried each run and keeps its place in the queue.
            Within a run the events kept are the first
            `max_shakemap_events` in the order the networks returned
            them, which `orderby=` controls (`"magnitude"` puts the
            largest first, the usual intent when capping). Raise the
            ceiling deliberately for a large job, or narrow the
            query with `limit=` / `min_magnitude=`.
        shakemap_layers: Which ShakeMap grids to write when
            `with_shakemap` is on. `None` (the default) writes
            `["mmi_mean"]` — macroseismic intensity, the headline
            shaking field. The archive carries fourteen grids
            (`mmi`, `pga`, `pgv`, `psa0p3`, `psa0p6`, `psa1p0`,
            `psa3p0`, each `_mean` and `_std`); all are reachable,
            none but the default are written unless asked for.
            Ignored when `with_shakemap` is `False`.

    Raises:
        ValueError: If `file_format` is not a supported vector
            format, if `limit` is zero or negative, or if
            `shakemap_layers` names a grid the archive does not
            carry.
        TypeError: If `variables` is a mapping — for this backend it
            selects seismic networks, not data variables.
    """
    self._min_magnitude = min_magnitude
    self._max_magnitude = max_magnitude
    self._min_depth = min_depth
    self._max_depth = max_depth
    self._magnitude_type = magnitude_type
    self._event_type = event_type
    self._orderby = orderby
    # Not `self._limit`: the base class owns that name for the
    # client-side total cap, and a provider storing its own meaning there
    # is how usgs_water silently lost its server-side limit. Validated
    # here so a zero/negative cap is refused before it reaches the FDSN
    # query, where it would be a server-side argument whose meaning varies
    # by provider rather than an obvious client-side bug.
    self._request_limit = self.check_limit(limit)
    self._earthscope_token_arg = earthscope_token
    self._earthscope_token: str | None = None
    if file_format not in _DRIVERS:
        raise ValueError(
            f"file_format must be one of {sorted(_DRIVERS)}, got {file_format!r}."
        )
    self._file_format: FileFormat = file_format
    self._with_shakemap = bool(with_shakemap)
    # Validated even when the flag is off, so a typo'd layer name is a
    # construction-time error rather than a surprise the day someone
    # turns the flag on.
    self._shakemap_layers = _helpers.normalize_layers(shakemap_layers)
    # Validated inline rather than through `check_limit`, which is typed
    # for an optional cap: this one is always an int, and keeping it that
    # way lets the ceiling comparison stay a plain `>`.
    if not isinstance(max_shakemap_events, int) or isinstance(
        max_shakemap_events, bool
    ):
        raise TypeError(
            "max_shakemap_events must be an integer, got "
            f"{type(max_shakemap_events).__name__}."
        )
    if max_shakemap_events <= 0:
        raise ValueError(
            "max_shakemap_events must be a positive integer, got "
            f"{max_shakemap_events!r}."
        )
    self._max_shakemap_events: int = max_shakemap_events
    self._http: HttpClient | None = None
    if isinstance(variables, dict):
        raise TypeError(
            "FDSN `variables` must be a list of network keys (e.g. "
            "['USGS', 'EMSC']), not a mapping. For this backend "
            "`variables` selects seismic networks, not data variables; "
            "query filters are explicit FDSN(...) keyword arguments."
        )
    self._catalog = Catalog()
    super().__init__(
        start=start,
        end=end,
        variables=list(variables) or list(_DEFAULT_PROVIDERS),
        temporal_resolution=temporal_resolution,
        lat_lim=lat_lim,
        lon_lim=lon_lim,
        fmt=fmt,
        path=path,
    )

download(progress_bar=True, errors='warn', limit=None, force=False) #

Query every requested network and return the unioned events.

Each network in self.vars is queried once; its events are written to one vector file under path (named after the network), and the per-network results are concatenated into the single :class:FeatureCollection returned. A network that errors is logged and skipped (its events are simply absent from the union) rather than aborting the whole request; only a total failure — every network errored — raises. An all-empty result (every network matched nothing) returns a schema-correct empty FeatureCollection and writes nothing.

When the instance was built with with_shakemap=True, each USGS event additionally gets its ShakeMap grids written as GeoTIFFs under path/shakemap/<event>/. Those rasters are a side effect only — the return value stays the event FeatureCollection. An event whose ShakeMap cannot be fetched is logged and skipped under the same errors= policy as a failed network, so one missing grid never costs the event table.

Parameters:

Name Type Description Default
progress_bar bool

Whether to show a progress bar. obspy's get_events has none, so this only governs the ShakeMap loop, which is the long part of a with_shakemap=True call; a plain event query ignores it.

True
errors str

Partial-failure policy, applied to both the per-network query loop and the per-event ShakeMap loop. "warn" (the default) logs each failure and continues, "ignore" continues silently, and "raise" propagates the first failure. Note what "raise" costs on a with_shakemap=True call: the events have already been fetched and written by then, but raising out of download() means they are not returned, so a single missing ShakeMap loses the whole in-memory event table. Leave it at "warn" unless a missing raster should genuinely abort the request.

'warn'
force bool

Refetch a ShakeMap whose GeoTIFFs are already on disk instead of reusing them. A download() argument, not a constructor one — EarthLens(...).download(force=True), not EarthLens(force=True, ...). The rasters are written atomically, so a present file is a finished one and the default reuse is safe; this is the escape hatch for a file damaged after the fact, or for picking up a revised ShakeMap for an event USGS has since updated.

False
limit int | None

Maximum events per network, overriding the constructor's limit= for this call. Same meaning as that one — pushed into the FDSN query itself, so a per-network server-side cap rather than a total across networks — and accepted here so it can be passed through EarthLens(...).download(limit=...) like the other bounded backends. None (the default) keeps whatever the constructor set — note that a value passed here is sticky: it replaces the constructor's for this and every later call on the same instance, as force= and errors= do not.

None

Returns:

Name Type Description
FeatureCollection FeatureCollection

The row-wise union of every requested network's events, CRS EPSG:4326. Empty (schema-only) when no network matched anything.

Raises:

Type Description
RuntimeError

If every requested network's query failed (propagated from :meth:_fetch). A partial failure of the network loop does not raise — the healthy networks' events are returned. The ShakeMap loop is governed by the same errors= policy, so under the default "warn" a failed raster does not raise either; under errors="raise" it does, and because the raise escapes download() the event table is not returned even though its files are already on disk.

Source code in libs/providers/hazards/src/earthlens/fdsn/backend.py
def download(
    self,
    progress_bar: bool = True,
    errors: str = "warn",
    limit: int | None = None,
    force: bool = False,
) -> FeatureCollection:
    """Query every requested network and return the unioned events.

    Each network in `self.vars` is queried once; its events are
    written to one vector file under `path` (named after the
    network), and the per-network results are concatenated into the
    single :class:`FeatureCollection` returned. A network that
    errors is logged and skipped (its events are simply absent from
    the union) rather than aborting the whole request; only a total
    failure — every network errored — raises. An all-empty result
    (every network matched nothing) returns a schema-correct empty
    FeatureCollection and writes nothing.

    When the instance was built with `with_shakemap=True`, each USGS
    event additionally gets its ShakeMap grids written as GeoTIFFs
    under `path/shakemap/<event>/`. Those rasters are a side effect
    only — the return value stays the event FeatureCollection. An
    event whose ShakeMap cannot be fetched is logged and skipped
    under the same `errors=` policy as a failed network, so one
    missing grid never costs the event table.

    Args:
        progress_bar: Whether to show a progress bar. obspy's
            `get_events` has none, so this only governs the ShakeMap
            loop, which is the long part of a `with_shakemap=True`
            call; a plain event query ignores it.
        errors: Partial-failure policy, applied to **both** the
            per-network query loop and the per-event ShakeMap loop.
            `"warn"` (the default) logs each failure and continues,
            `"ignore"` continues silently, and `"raise"` propagates
            the first failure. Note what `"raise"` costs on a
            `with_shakemap=True` call: the events have already been
            fetched and written by then, but raising out of
            `download()` means they are not *returned*, so a single
            missing ShakeMap loses the whole in-memory event table.
            Leave it at `"warn"` unless a missing raster should
            genuinely abort the request.
        force: Refetch a ShakeMap whose GeoTIFFs are already on
            disk instead of reusing them. A `download()` argument,
            not a constructor one — `EarthLens(...).download(force=True)`,
            not `EarthLens(force=True, ...)`. The rasters are written
            atomically, so a present file is a finished one and the
            default reuse is safe; this is the escape hatch for a
            file damaged after the fact, or for picking up a
            revised ShakeMap for an event USGS has since updated.
        limit: Maximum events **per network**, overriding the constructor's
            `limit=` for this call. Same meaning as that one — pushed into
            the FDSN query itself, so a per-network server-side cap rather
            than a total across networks — and accepted here so it can be
            passed through `EarthLens(...).download(limit=...)` like the
            other bounded backends. `None` (the default) keeps whatever the
            constructor set — note that a value passed here is **sticky**:
            it replaces the constructor's for this and every later call on
            the same instance, as `force=` and `errors=` do not.

    Returns:
        FeatureCollection: The row-wise union of every requested
            network's events, CRS `EPSG:4326`. Empty (schema-only)
            when no network matched anything.

    Raises:
        RuntimeError: If **every** requested network's query failed
            (propagated from :meth:`_fetch`). A partial failure of
            the *network* loop does not raise — the healthy
            networks' events are returned. The ShakeMap loop is
            governed by the same `errors=` policy, so under the
            default `"warn"` a failed raster does not raise either;
            under `errors="raise"` it does, and because the raise
            escapes `download()` the event table is not returned
            even though its files are already on disk.
    """
    if limit is not None:
        self._request_limit = self.check_limit(limit)
    self._errors = self.check_errors_policy(errors)
    self._force = force
    products = self._search()
    collections = self._fetch(products) if products else []

    written: list[Path] = []
    for product, collection in zip(products, collections):
        if len(collection):
            written.append(self._write(product.id, collection))

    rasters: list[Path] = []
    if self._with_shakemap:
        # Before the concat: the per-network split is what says which
        # events came from USGS, and the union has thrown that away.
        try:
            rasters = self._download_shakemaps(
                products, collections, progress_bar=progress_bar
            )
        finally:
            self._close_client()

    combined = events.concat_fcs(collections)
    # `concat_fcs` has copied every network's events, so the per-network
    # copies are dead weight from here on. This does not lower the *peak* —
    # that was reached inside the concat, with both sets live — but it stops
    # them being held through the summary, the return, and however long the
    # caller keeps the result.
    collections.clear()
    if written:
        # "available" rather than "written": a re-run reuses rasters that
        # were already on disk, and calling those written would overstate
        # what this call did.
        raster_note = (
            f" plus {len(rasters)} ShakeMap raster(s) available"
            if self._with_shakemap
            else ""
        )
        logger.info(
            f"FDSN download summary: {len(combined)} events across "
            f"{len(written)} file(s){raster_note} written to {self.root_dir}"
        )
    else:
        logger.warning(
            "FDSN download summary: no events matched the request, nothing written"
        )
    return combined

Provider #

Bases: BaseModel

One FDSN-event network's dispatch row.

The user-facing name is the parent key in :attr:Catalog.providers (and :attr:Catalog.datasets) and is not stored on the row.

Attributes:

Name Type Description
fdsn_id str

obspy URL_MAPPINGS key (e.g. "USGS") or an explicit base URL the obspy.clients.fdsn.Client constructor accepts. This is what actually selects the web service.

title str

Human-readable description used in logs and docs.

needs_token bool

Whether this network's event service requires an access token. False for the public networks; the FDSN event endpoints are public, so this is informational and only consulted when a token has been supplied.

default_min_magnitude float

Network-appropriate default lower magnitude bound (regional networks like INGV report much smaller events than the global catalogs). Advisory — the backend's own min_magnitude kwarg takes precedence.

docs_url str

Link to the network's FDSN event-service documentation.

Examples:

  • Build a row directly:
    >>> from earthlens.fdsn import Provider
    >>> p = Provider(fdsn_id="USGS", title="USGS ComCat")
    >>> p.fdsn_id, p.needs_token, p.default_min_magnitude
    ('USGS', False, 4.5)
    
Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
class Provider(BaseModel):
    """One FDSN-event network's dispatch row.

    The user-facing name is the parent key in
    :attr:`Catalog.providers` (and :attr:`Catalog.datasets`) and is not
    stored on the row.

    Attributes:
        fdsn_id: obspy `URL_MAPPINGS` key (e.g. `"USGS"`) or an
            explicit base URL the `obspy.clients.fdsn.Client`
            constructor accepts. This is what actually selects the
            web service.
        title: Human-readable description used in logs and docs.
        needs_token: Whether this network's event service requires an
            access token. `False` for the public networks; the FDSN
            event endpoints are public, so this is informational and
            only consulted when a token has been supplied.
        default_min_magnitude: Network-appropriate default lower
            magnitude bound (regional networks like INGV report much
            smaller events than the global catalogs). Advisory — the
            backend's own `min_magnitude` kwarg takes precedence.
        docs_url: Link to the network's FDSN event-service
            documentation.

    Examples:
        - Build a row directly:
            ```python
            >>> from earthlens.fdsn import Provider
            >>> p = Provider(fdsn_id="USGS", title="USGS ComCat")
            >>> p.fdsn_id, p.needs_token, p.default_min_magnitude
            ('USGS', False, 4.5)

            ```
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    fdsn_id: str
    title: str = ""
    needs_token: bool = False
    default_min_magnitude: float = 4.5
    docs_url: str = ""

catalog_to_fc(catalog, provider) #

Convert an obspy Catalog into a FeatureCollection of events.

One row per event, columns per :data:ATTRIBUTE_COLUMNS plus a geometry column of shapely.Point(longitude, latitude) in EPSG:4326. An event with no usable origin (no preferred origin and an empty origins list) still contributes a row, but its geometry is None rather than an invalid POINT (nan nan) that would corrupt a written GeoPackage/GeoJSON. An empty catalog returns an empty FeatureCollection with the same columns/dtypes (see :func:empty_fc) so the result type is identical whether or not the query matched anything.

Parameters:

Name Type Description Default
catalog Catalog

An obspy.core.event.Catalog (iterable over Event), typically the return value of obspy.clients.fdsn.Client.get_events.

required
provider str

The user-facing provider key ("USGS", "EMSC", …) recorded in the provider column of every row.

required

Returns:

Name Type Description
FeatureCollection FeatureCollection

One feature per event, CRS EPSG:4326; rows whose event lacked a usable origin carry a null geometry.

Examples:

  • Map a one-event catalog and read back a field:
    >>> from obspy.core.event import Catalog, Event, Origin, Magnitude
    >>> from obspy import UTCDateTime
    >>> origin = Origin(
    ...     time=UTCDateTime("2024-01-01T00:00:00"),
    ...     longitude=12.5, latitude=42.0, depth=10000.0,
    ...     evaluation_status="reviewed",
    ... )
    >>> magnitude = Magnitude(mag=5.2, magnitude_type="Mw")
    >>> event = Event(
    ...     origins=[origin], magnitudes=[magnitude],
    ...     event_type="earthquake",
    ... )
    >>> from earthlens.fdsn.events import catalog_to_fc
    >>> fc = catalog_to_fc(Catalog(events=[event]), "USGS")
    >>> len(fc)
    1
    >>> float(fc["depth_km"].iloc[0])
    10.0
    >>> fc["provider"].iloc[0]
    'USGS'
    >>> fc.crs.to_epsg()
    4326
    
Source code in libs/providers/hazards/src/earthlens/fdsn/events.py
def catalog_to_fc(catalog: Catalog, provider: str) -> FeatureCollection:
    """Convert an obspy `Catalog` into a `FeatureCollection` of events.

    One row per event, columns per :data:`ATTRIBUTE_COLUMNS` plus a
    `geometry` column of `shapely.Point(longitude, latitude)` in
    `EPSG:4326`. An event with no usable origin (no preferred origin
    and an empty `origins` list) still contributes a row, but its
    `geometry` is `None` rather than an invalid `POINT (nan nan)` that
    would corrupt a written GeoPackage/GeoJSON. An empty catalog
    returns an empty FeatureCollection with the same columns/dtypes
    (see :func:`empty_fc`) so the result type is identical whether or
    not the query matched anything.

    Args:
        catalog: An `obspy.core.event.Catalog` (iterable over
            `Event`), typically the return value of
            `obspy.clients.fdsn.Client.get_events`.
        provider: The user-facing provider key (`"USGS"`, `"EMSC"`,
            …) recorded in the `provider` column of every row.

    Returns:
        FeatureCollection: One feature per event, CRS `EPSG:4326`;
            rows whose event lacked a usable origin carry a null
            geometry.

    Examples:
        - Map a one-event catalog and read back a field:
            ```python
            >>> from obspy.core.event import Catalog, Event, Origin, Magnitude
            >>> from obspy import UTCDateTime
            >>> origin = Origin(
            ...     time=UTCDateTime("2024-01-01T00:00:00"),
            ...     longitude=12.5, latitude=42.0, depth=10000.0,
            ...     evaluation_status="reviewed",
            ... )
            >>> magnitude = Magnitude(mag=5.2, magnitude_type="Mw")
            >>> event = Event(
            ...     origins=[origin], magnitudes=[magnitude],
            ...     event_type="earthquake",
            ... )
            >>> from earthlens.fdsn.events import catalog_to_fc
            >>> fc = catalog_to_fc(Catalog(events=[event]), "USGS")
            >>> len(fc)
            1
            >>> float(fc["depth_km"].iloc[0])
            10.0
            >>> fc["provider"].iloc[0]
            'USGS'
            >>> fc.crs.to_epsg()
            4326

            ```
    """
    rows = [_event_to_row(event, provider) for event in catalog]
    if not rows:
        return empty_fc()

    frame = pd.DataFrame(rows, columns=list(ATTRIBUTE_COLUMNS))
    frame["time"] = pd.to_datetime(frame["time"], utc=True)
    for column, dtype in ATTRIBUTE_COLUMNS.items():
        if column == "time":
            continue
        frame[column] = frame[column].astype(dtype)

    # Build the Point per row so an event with no usable origin (NaN
    # longitude/latitude) gets a null geometry rather than an invalid
    # POINT (nan nan) that would corrupt a written GeoPackage/GeoJSON.
    points = [
        Point(lon, lat) if pd.notna(lon) and pd.notna(lat) else None
        for lon, lat in zip(frame["longitude"], frame["latitude"])
    ]
    gdf = gpd.GeoDataFrame(
        frame, geometry=gpd.GeoSeries(points, crs=EVENT_CRS), crs=EVENT_CRS
    )
    return FeatureCollection(gdf)

empty_fc() #

Return an empty FeatureCollection with the canonical event schema.

Used for a query that matched nothing (a quiet region/time, an HTTP-204 FDSNNoDataException) so callers always get the same columns and dtypes back regardless of hit count — an empty result is a legitimate answer, not an error.

Returns:

Name Type Description
FeatureCollection FeatureCollection

Zero rows, the :data:ATTRIBUTE_COLUMNS columns with their declared dtypes, an empty geometry column, CRS EPSG:4326.

Examples:

  • The schema is present even with no rows:
    >>> from earthlens.fdsn.events import empty_fc, ATTRIBUTE_COLUMNS
    >>> fc = empty_fc()
    >>> len(fc)
    0
    >>> set(ATTRIBUTE_COLUMNS).issubset(fc.columns)
    True
    >>> "geometry" in fc.columns
    True
    >>> fc.crs.to_epsg()
    4326
    
Source code in libs/providers/hazards/src/earthlens/fdsn/events.py
def empty_fc() -> FeatureCollection:
    """Return an empty `FeatureCollection` with the canonical event schema.

    Used for a query that matched nothing (a quiet region/time, an
    HTTP-204 `FDSNNoDataException`) so callers always get the same
    columns and dtypes back regardless of hit count — an empty result
    is a legitimate answer, not an error.

    Returns:
        FeatureCollection: Zero rows, the :data:`ATTRIBUTE_COLUMNS`
            columns with their declared dtypes, an empty `geometry`
            column, CRS `EPSG:4326`.

    Examples:
        - The schema is present even with no rows:
            ```python
            >>> from earthlens.fdsn.events import empty_fc, ATTRIBUTE_COLUMNS
            >>> fc = empty_fc()
            >>> len(fc)
            0
            >>> set(ATTRIBUTE_COLUMNS).issubset(fc.columns)
            True
            >>> "geometry" in fc.columns
            True
            >>> fc.crs.to_epsg()
            4326

            ```
    """
    frame = pd.DataFrame(
        {
            column: pd.Series([], dtype=dtype)
            for column, dtype in ATTRIBUTE_COLUMNS.items()
        }
    )
    gdf = gpd.GeoDataFrame(
        frame, geometry=gpd.GeoSeries([], crs=EVENT_CRS), crs=EVENT_CRS
    )
    return FeatureCollection(gdf)

resolve_earthscope_token(token=None) #

Resolve an optional EarthScope access token.

Resolution order:

  1. The explicit token argument, if non-empty.
  2. The EARTHSCOPE_TOKEN environment variable.
  3. A ~/.earthscope_token file (first non-empty line).

Parameters:

Name Type Description Default
token str | None

An explicit token passed by the caller, or None.

None

Returns:

Type Description
str | None

The resolved token string, or None when no source supplies one (the normal case for the public event services).

Examples:

  • An explicit token wins and is returned unchanged:
    >>> from earthlens.fdsn.auth import resolve_earthscope_token
    >>> resolve_earthscope_token("example-token")
    'example-token'
    
  • With no argument, env var, or file, the result is None:
    >>> import os
    >>> from earthlens.fdsn.auth import resolve_earthscope_token
    >>> os.environ.pop("EARTHSCOPE_TOKEN", None)  # doctest: +SKIP
    >>> resolve_earthscope_token()  # doctest: +SKIP
    
Source code in libs/providers/hazards/src/earthlens/fdsn/auth.py
def resolve_earthscope_token(token: str | None = None) -> str | None:
    """Resolve an optional EarthScope access token.

    Resolution order:

    1. The explicit `token` argument, if non-empty.
    2. The `EARTHSCOPE_TOKEN` environment variable.
    3. A `~/.earthscope_token` file (first non-empty line).

    Args:
        token: An explicit token passed by the caller, or `None`.

    Returns:
        The resolved token string, or `None` when no source supplies
            one (the normal case for the public event services).

    Examples:
        - An explicit token wins and is returned unchanged:
            ```python
            >>> from earthlens.fdsn.auth import resolve_earthscope_token
            >>> resolve_earthscope_token("example-token")
            'example-token'

            ```
        - With no argument, env var, or file, the result is `None`:
            ```python
            >>> import os
            >>> from earthlens.fdsn.auth import resolve_earthscope_token
            >>> os.environ.pop("EARTHSCOPE_TOKEN", None)  # doctest: +SKIP
            >>> resolve_earthscope_token()  # doctest: +SKIP

            ```
    """
    if token:
        return token
    env_token = os.environ.get("EARTHSCOPE_TOKEN")
    if env_token:
        return env_token
    if EARTHSCOPE_TOKEN_FILE.is_file():
        for line in EARTHSCOPE_TOKEN_FILE.read_text(encoding="utf-8").splitlines():
            stripped = line.strip()
            if stripped:
                return stripped
    return None

earthlens.fdsn.backend #

Backend that queries FDSN-event seismological networks via obspy.

FDSN(AbstractDataSource) fetches earthquake / seismic-event catalogs from the six IRIS-FDSN-event networks earthlens ships with — USGS (ComCat), EMSC (seismicportal), INGV (Italian seismic + volcano), EarthScope (ex-IRIS DMC), ISC (global reviewed bulletin), and GeoNet (New Zealand) — through one obspy.clients.fdsn.Client per network, because they all speak the identical FDSN-event web-service standard. Each provider key in variables becomes one server-side get_events call; the per-network obspy.core.event.Catalog is mapped to a pyramids :class:~pyramids.feature.collection.FeatureCollection by :mod:earthlens.fdsn.events.

This is the first vector backend: the on-the-wire result is a table of point features, not a gridded array, so OUTPUT_KIND = "vector" and an aggregate= argument is refused (there is no meaningful gridded reduction of an event table). The refusal itself lives in :meth:earthlens.base.AbstractDataSource._refuse_unsupported_aggregate, which every backend's download routes through, so a direct FDSN(...).download(aggregate=...) is rejected identically to one made through the :class:earthlens.earthlens.EarthLens facade. download() returns the in-memory FeatureCollection (the union across requested networks) and, as a side effect, writes one vector file per network to path.

Optionally the backend also writes a raster side-output: with with_shakemap=True each USGS event's gridded ShakeMap is fetched and written as a GeoTIFF alongside the vector files. This does not change OUTPUT_KINDdownload() still returns the event FeatureCollection and the rasters are a side effect on disk. ShakeMap is a USGS ComCat product and is not exposed through the FDSN event standard at all, so that path is USGS-only and costs one extra request per event — see :mod:earthlens.fdsn._helpers.

Provider selection follows the FDSN-specific reading of variables (see the package docstring): variables is a list[str] of network keys (["USGS"], ["USGS", "EMSC"]); query filters arrive as explicit constructor kwargs. The temporal window is a single unchunked [start, end] get_events call — FDSN does not iterate per day/month — so temporal_resolution carries the sentinel "all".

FDSN #

Bases: AbstractDataSource

FDSN seismic-event backend (vector point-feature output).

Wraps obspy.clients.fdsn.Client.get_events so a user can pull a space/time/magnitude window of seismic events from one or more FDSN networks through the same download() shape every other earthlens backend uses. Each network key in variables becomes one server-side query; the per-network catalog is mapped to a :class:~pyramids.feature.collection.FeatureCollection and the networks' results are unioned into the single FeatureCollection download() returns.

The public event services need no credentials; the optional EarthScope token is resolved lazily and only used for a provider whose catalog row declares needs_token: true.

Attributes:

Name Type Description
OUTPUT_KIND OutputKind

"vector" — the result is a table of point features (events), so aggregate= is refused with NotImplementedError. It stays "vector" even under with_shakemap=True: OUTPUT_KIND describes what download() returns, and that is always a FeatureCollection. The ShakeMap GeoTIFFs are an on-disk side effect, not a second return shape — "mixed" is reserved for a backend whose returned format is only known at download time and which honours aggregate= itself.

Examples:

  • Build a plain event query and inspect what it resolved:
    >>> from earthlens.fdsn import FDSN
    >>> backend = FDSN(
    ...     start="2024-01-01",
    ...     end="2024-01-31",
    ...     variables=["USGS"],
    ...     lat_lim=[30.0, 45.0],
    ...     lon_lim=[130.0, 145.0],
    ... )
    >>> backend.vars
    ['USGS']
    >>> backend.time.resolution
    'all'
    >>> backend.space.south, backend.space.east
    (30.0, 145.0)
    
  • An empty network list falls back to USGS, and the products the query will issue carry the resolved obspy client id:
    >>> from earthlens.fdsn import FDSN
    >>> backend = FDSN(
    ...     start="2024-01-01",
    ...     end="2024-01-02",
    ...     variables=[],
    ...     lat_lim=[-90.0, 90.0],
    ...     lon_lim=[-180.0, 180.0],
    ... )
    >>> backend.vars
    ['USGS']
    >>> [product.metadata["fdsn_id"] for product in backend._search()]
    ['USGS']
    
  • Asking for the ShakeMap side-output selects one grid by default and leaves the returned shape a vector table:
    >>> from earthlens.fdsn import FDSN
    >>> backend = FDSN(
    ...     start="2023-02-06",
    ...     end="2023-02-07",
    ...     variables=["USGS"],
    ...     lat_lim=[35.0, 39.0],
    ...     lon_lim=[35.0, 39.0],
    ...     with_shakemap=True,
    ... )
    >>> backend.OUTPUT_KIND
    'vector'
    >>> backend._shakemap_layers
    ('mmi_mean',)
    
Source code in libs/providers/hazards/src/earthlens/fdsn/backend.py
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
class FDSN(AbstractDataSource):
    """FDSN seismic-event backend (vector point-feature output).

    Wraps `obspy.clients.fdsn.Client.get_events` so a user can pull a
    space/time/magnitude window of seismic events from one or more
    FDSN networks through the same `download()` shape every other
    earthlens backend uses. Each network key in `variables` becomes
    one server-side query; the per-network catalog is mapped to a
    :class:`~pyramids.feature.collection.FeatureCollection` and the
    networks' results are unioned into the single FeatureCollection
    `download()` returns.

    The public event services need no credentials; the optional
    EarthScope token is resolved lazily and only used for a provider
    whose catalog row declares `needs_token: true`.

    Attributes:
        OUTPUT_KIND: `"vector"` — the result is a table of point
            features (events), so `aggregate=` is refused with
            `NotImplementedError`. It stays `"vector"` even under
            `with_shakemap=True`: `OUTPUT_KIND` describes what
            `download()` *returns*, and that is always a
            FeatureCollection. The ShakeMap GeoTIFFs are an on-disk
            side effect, not a second return shape — `"mixed"` is
            reserved for a backend whose returned format is only known
            at download time and which honours `aggregate=` itself.

    Examples:
        - Build a plain event query and inspect what it resolved:
            ```python
            >>> from earthlens.fdsn import FDSN
            >>> backend = FDSN(
            ...     start="2024-01-01",
            ...     end="2024-01-31",
            ...     variables=["USGS"],
            ...     lat_lim=[30.0, 45.0],
            ...     lon_lim=[130.0, 145.0],
            ... )
            >>> backend.vars
            ['USGS']
            >>> backend.time.resolution
            'all'
            >>> backend.space.south, backend.space.east
            (30.0, 145.0)

            ```
        - An empty network list falls back to USGS, and the products the
          query will issue carry the resolved obspy client id:
            ```python
            >>> from earthlens.fdsn import FDSN
            >>> backend = FDSN(
            ...     start="2024-01-01",
            ...     end="2024-01-02",
            ...     variables=[],
            ...     lat_lim=[-90.0, 90.0],
            ...     lon_lim=[-180.0, 180.0],
            ... )
            >>> backend.vars
            ['USGS']
            >>> [product.metadata["fdsn_id"] for product in backend._search()]
            ['USGS']

            ```
        - Asking for the ShakeMap side-output selects one grid by default
          and leaves the returned shape a vector table:
            ```python
            >>> from earthlens.fdsn import FDSN
            >>> backend = FDSN(
            ...     start="2023-02-06",
            ...     end="2023-02-07",
            ...     variables=["USGS"],
            ...     lat_lim=[35.0, 39.0],
            ...     lon_lim=[35.0, 39.0],
            ...     with_shakemap=True,
            ... )
            >>> backend.OUTPUT_KIND
            'vector'
            >>> backend._shakemap_layers
            ('mmi_mean',)

            ```
    """

    OUTPUT_KIND: OutputKind = "vector"

    AGGREGATE_REFUSAL_REASON = "seismic events are vector point features, not gridded rasters, so there is no meaningful gridded reduction — and the optional with_shakemap= rasters are a per-event side-output with no time axis to reduce over. Call download() without aggregate= and post-process the returned FeatureCollection (a GeoDataFrame) directly"

    #: Partial-failure policy for the per-provider loop; `download(errors=...)`
    #: overrides it per call.
    _errors: str = "warn"

    #: Whether `download(force=...)` asked for cached ShakeMap rasters to be
    #: refetched rather than reused.
    _force: bool = False

    def __init__(
        self,
        start: str,
        end: str,
        variables: list[str],
        lat_lim: list[float],
        lon_lim: list[float],
        temporal_resolution: str = "all",
        path: Path | str | None = None,
        fmt: str = "%Y-%m-%d",
        min_magnitude: float | None = None,
        max_magnitude: float | None = None,
        min_depth: float | None = None,
        max_depth: float | None = None,
        magnitude_type: str | None = None,
        event_type: str | None = None,
        orderby: str = "time",
        limit: int | None = None,
        earthscope_token: str | None = None,
        file_format: FileFormat = "gpkg",
        with_shakemap: bool = False,
        shakemap_layers: list[str] | None = None,
        max_shakemap_events: int = _DEFAULT_MAX_SHAKEMAP_EVENTS,
    ):
        """Initialise an FDSN backend instance.

        Args:
            start: Inclusive start of the event window, as a string
                parsed with `fmt`.
            end: Inclusive end of the event window.
            variables: List of FDSN network keys to query (`["USGS"]`,
                `["USGS", "EMSC"]`). For this backend `variables`
                names the seismic *networks*, not data variables (see
                the package docstring). An empty list defaults to
                `["USGS"]`.
            lat_lim: `[lat_min, lat_max]` bounding-box latitudes in
                degrees, both in `[-90, 90]`.
            lon_lim: `[lon_min, lon_max]` bounding-box longitudes in
                degrees, both in `[-180, 180]`.
            temporal_resolution: FDSN does not chunk by day/month — the
                whole `[start, end]` window is one query — so this is
                the sentinel `"all"`, not a pandas frequency alias.
            path: Output directory for the per-network vector files.
                Created by the parent class if absent.
            fmt: `strptime` format for `start` / `end`.
            min_magnitude: Lower magnitude bound. `None` (the default)
                falls back per network to that provider's
                `default_min_magnitude` catalog value (USGS / EMSC /
                EarthScope / ISC = 4.5, INGV = 2.0, GeoNet = 3.0), so
                each regional network keeps a sensible floor. Pass an
                explicit number to override every network with one
                bound.
            max_magnitude: Upper magnitude bound, or `None`.
            min_depth: Lower depth bound in kilometres, or `None`.
            max_depth: Upper depth bound in kilometres, or `None`.
            magnitude_type: Restrict to a magnitude type (e.g.
                `"Mw"`), or `None` for any.
            event_type: Restrict to an event type (e.g.
                `"earthquake"`, `"volcanic eruption"`), or `None`.
            orderby: Result ordering — `"time"`, `"time-asc"`,
                `"magnitude"`, or `"magnitude-asc"`.
            limit: Maximum number of events **per network**, or `None` for no
                cap. Unlike the total-row `limit=` the tabular backends take,
                this is pushed into the FDSN query itself, so a capped request
                never transfers the events past the cap; with several networks
                the totals add up rather than being one overall ceiling.
                Rejected if zero or negative.
            earthscope_token: Optional EarthScope access token; falls
                back to `EARTHSCOPE_TOKEN` / `~/.earthscope_token`.
                Used only for a provider that requires a token.
            file_format: Output vector format — `"gpkg"` (default,
                GeoPackage) or `"geojson"`.
            with_shakemap: Also fetch each event's gridded ShakeMap and
                write it as a GeoTIFF under `path/shakemap/<event>/`.
                **USGS only** — ShakeMap is a ComCat product, not part
                of the FDSN event standard, so a non-USGS network in the
                same request contributes events but no rasters. Costs
                one extra ComCat request plus a multi-megabyte archive per
                event, so bound the event count with `limit=` before
                turning it on for a busy window; beyond
                `max_shakemap_events` the side-output stops and says how
                many events it skipped. It does not change
                `OUTPUT_KIND` — `download()` still returns the event
                FeatureCollection, and the rasters are a side effect.
            max_shakemap_events: Ceiling on how many events one call
                will fetch a ShakeMap for, guarding against a broad
                query quietly pulling gigabytes (each event is a
                separate request plus a multi-megabyte archive). Events past
                the ceiling are deferred with a warning naming the
                count — never silently. The ceiling counts *fetches*,
                not events: an event already satisfied on disk costs no
                budget, and an id that can never be fetched at all is
                dropped before counting. So re-running the same request
                takes the next batch, and repeating it eventually walks
                the whole list. The one exception is an event that fails
                on every attempt: a failure is not cached, so it is
                retried each run and keeps its place in the queue.
                Within a run the events kept are the first
                `max_shakemap_events` in the order the networks returned
                them, which `orderby=` controls (`"magnitude"` puts the
                largest first, the usual intent when capping). Raise the
                ceiling deliberately for a large job, or narrow the
                query with `limit=` / `min_magnitude=`.
            shakemap_layers: Which ShakeMap grids to write when
                `with_shakemap` is on. `None` (the default) writes
                `["mmi_mean"]` — macroseismic intensity, the headline
                shaking field. The archive carries fourteen grids
                (`mmi`, `pga`, `pgv`, `psa0p3`, `psa0p6`, `psa1p0`,
                `psa3p0`, each `_mean` and `_std`); all are reachable,
                none but the default are written unless asked for.
                Ignored when `with_shakemap` is `False`.

        Raises:
            ValueError: If `file_format` is not a supported vector
                format, if `limit` is zero or negative, or if
                `shakemap_layers` names a grid the archive does not
                carry.
            TypeError: If `variables` is a mapping — for this backend it
                selects seismic networks, not data variables.
        """
        self._min_magnitude = min_magnitude
        self._max_magnitude = max_magnitude
        self._min_depth = min_depth
        self._max_depth = max_depth
        self._magnitude_type = magnitude_type
        self._event_type = event_type
        self._orderby = orderby
        # Not `self._limit`: the base class owns that name for the
        # client-side total cap, and a provider storing its own meaning there
        # is how usgs_water silently lost its server-side limit. Validated
        # here so a zero/negative cap is refused before it reaches the FDSN
        # query, where it would be a server-side argument whose meaning varies
        # by provider rather than an obvious client-side bug.
        self._request_limit = self.check_limit(limit)
        self._earthscope_token_arg = earthscope_token
        self._earthscope_token: str | None = None
        if file_format not in _DRIVERS:
            raise ValueError(
                f"file_format must be one of {sorted(_DRIVERS)}, got {file_format!r}."
            )
        self._file_format: FileFormat = file_format
        self._with_shakemap = bool(with_shakemap)
        # Validated even when the flag is off, so a typo'd layer name is a
        # construction-time error rather than a surprise the day someone
        # turns the flag on.
        self._shakemap_layers = _helpers.normalize_layers(shakemap_layers)
        # Validated inline rather than through `check_limit`, which is typed
        # for an optional cap: this one is always an int, and keeping it that
        # way lets the ceiling comparison stay a plain `>`.
        if not isinstance(max_shakemap_events, int) or isinstance(
            max_shakemap_events, bool
        ):
            raise TypeError(
                "max_shakemap_events must be an integer, got "
                f"{type(max_shakemap_events).__name__}."
            )
        if max_shakemap_events <= 0:
            raise ValueError(
                "max_shakemap_events must be a positive integer, got "
                f"{max_shakemap_events!r}."
            )
        self._max_shakemap_events: int = max_shakemap_events
        self._http: HttpClient | None = None
        if isinstance(variables, dict):
            raise TypeError(
                "FDSN `variables` must be a list of network keys (e.g. "
                "['USGS', 'EMSC']), not a mapping. For this backend "
                "`variables` selects seismic networks, not data variables; "
                "query filters are explicit FDSN(...) keyword arguments."
            )
        self._catalog = Catalog()
        super().__init__(
            start=start,
            end=end,
            variables=list(variables) or list(_DEFAULT_PROVIDERS),
            temporal_resolution=temporal_resolution,
            lat_lim=lat_lim,
            lon_lim=lon_lim,
            fmt=fmt,
            path=path,
        )

    def _initialize(self):
        """Resolve the optional EarthScope token; build no global client.

        FDSN clients are per-network and built lazily in :meth:`_fetch`,
        so there is no shared client to bind to `self.client`. The only
        global state is the optional EarthScope token, resolved here so
        a missing-token situation surfaces once up front rather than per
        query.

        Returns:
            None: No per-instance client object.
        """
        self._earthscope_token = resolve_earthscope_token(self._earthscope_token_arg)
        return None

    def _check_input_dates(
        self,
        start: str,
        end: str,
        temporal_resolution: str,
        fmt: str,
    ) -> TemporalExtent:
        """Parse the `[start, end]` window into a :class:`TemporalExtent`.

        FDSN issues a single `get_events` call spanning the whole
        window, so there is no per-date loop. The resolution is kept as
        the FDSN sentinel `"all"` (not a real pandas frequency alias —
        it means "single unchunked window, no per-date iteration") and
        `dates` collapses to the two endpoints.

        Args:
            start: Inclusive start date string.
            end: Inclusive end date string.
            temporal_resolution: Ignored beyond being recorded as the
                resolution label; FDSN always queries the full window.
            fmt: `strptime` format tried first for a string `start` /
                `end`; a non-matching string falls back to an ISO-8601
                parse, and a `datetime` / `date` ignores it.

        Returns:
            TemporalExtent: Frozen model with the parsed endpoints.

        Raises:
            ValueError: If `start` parses to a date later than `end`.
        """
        return self._whole_window_extent(start, end, fmt=fmt, resolution="all")

    def _search(self) -> list[RemoteProduct]:
        """One :class:`RemoteProduct` per requested network.

        Resolves each network key in `self.vars` against the bundled
        provider catalog (raising with a did-you-mean hint on an
        unknown key) and records the resolved `fdsn_id` / `needs_token`
        on the product metadata. No network call is made here.

        Returns:
            list[RemoteProduct]: One product per network key, in
                request order; `id` is the network key and `metadata`
                carries `fdsn_id` and `needs_token`.

        Raises:
            ValueError: If a key in `self.vars` is not a registered
                provider.
        """
        products: list[RemoteProduct] = []
        # De-duplicated, order preserved: a repeated key would otherwise issue
        # the same query twice and union the same events into the result.
        for key in dict.fromkeys(self.vars):
            provider: Provider = self._catalog.get_provider(key)
            products.append(
                RemoteProduct(
                    id=key,
                    metadata={
                        "fdsn_id": provider.fdsn_id,
                        "needs_token": provider.needs_token,
                        "default_min_magnitude": provider.default_min_magnitude,
                    },
                )
            )
        return products

    def _fetch(self, products: list[RemoteProduct]) -> list[FeatureCollection]:
        """Query every network and map each result to a FeatureCollection.

        Widens the inherited `-> list[Path]` contract: a vector backend
        returns in-memory :class:`FeatureCollection`s, not file paths.
        A network whose query matches nothing
        (`FDSNNoDataException`, HTTP 204) yields an empty
        FeatureCollection — an empty result is a legitimate answer for a
        quiet region/time.

        A network whose query *errors* (timeout, HTTP 5xx, service
        unavailable) is logged and skipped rather than aborting the
        whole request — one flaky network does not lose the events
        already fetched from healthy ones (mirrors the ECMWF/CMEMS
        "one bad item does not kill the batch" policy). The skipped
        network contributes an empty FeatureCollection so the returned
        list stays positionally aligned with `products`. Only a
        **total** failure (every network errored) raises.

        The partial-failure policy is whatever `download(errors=...)`
        recorded on the instance: `"warn"` (the default) logs each failed
        network and continues, `"raise"` propagates the first failure, and
        `"ignore"` continues silently. An all-failed batch raises
        regardless of the policy.

        Args:
            products: The list returned by :meth:`_search`.

        Returns:
            list[FeatureCollection]: One collection per product, in the
                same order; empty collections for no-data or failed
                networks.

        Raises:
            RuntimeError: When **every** network's query failed, so a
                caller cannot silently process nothing. The message
                aggregates the failed networks and their exception
                types; the per-network errors are logged at ERROR.
        """
        collections, failed = self._run_items(
            products,
            self._query_one,
            errors=self._errors,
            label="provider query",
            describe=lambda product: repr(product.id),
            on_failure=lambda _product, _exc: events.empty_fc(),
        )

        if failed and len(failed) == len(products):
            summary = ", ".join(
                f"{pid} ({type(exc).__name__}: {exc})" for pid, exc in failed
            )
            raise RuntimeError(
                f"all {len(failed)} FDSN provider query(ies) failed: "
                f"{summary}. See the per-provider ERROR logs above."
            )
        if failed:
            summary = ", ".join(f"{pid} ({type(exc).__name__})" for pid, exc in failed)
            logger.warning(
                f"{len(failed)} of {len(products)} FDSN provider query(ies) "
                f"failed and were skipped: {summary}"
            )
        return collections

    def _query_one(self, product: RemoteProduct) -> FeatureCollection:
        """Run one network's `get_events` and map it to a FeatureCollection.

        Args:
            product: One :class:`RemoteProduct` from :meth:`_search`;
                `product.id` is the network key and
                `product.metadata["fdsn_id"]` selects the obspy client.

        Returns:
            FeatureCollection: Events for this network (empty on a
                no-data response).
        """
        from obspy import UTCDateTime
        from obspy.clients.fdsn import Client
        from obspy.clients.fdsn.header import FDSNNoDataException

        from earthlens.core import __version__

        provider_key = product.id
        fdsn_id = product.metadata["fdsn_id"]
        needs_token = product.metadata.get("needs_token", False)

        # An explicit min_magnitude overrides every network; otherwise fall
        # back to this provider's catalog default so regional networks (INGV,
        # GeoNet) keep their lower floor instead of the global 4.5.
        min_magnitude = (
            self._min_magnitude
            if self._min_magnitude is not None
            else product.metadata.get("default_min_magnitude")
        )

        client_kwargs: dict[str, object] = {"user_agent": f"earthlens/{__version__}"}
        if needs_token and self._earthscope_token:
            # obspy's only token slot is `eida_token`. The bundled networks are
            # all public (needs_token=False), so this branch is opt-in: a
            # maintainer who adds a token-gated network must confirm that
            # network accepts an EIDA-style token before relying on it.
            client_kwargs["eida_token"] = self._earthscope_token
        client = Client(fdsn_id, **client_kwargs)

        logger.info(
            f"Querying FDSN provider {provider_key!r} ({fdsn_id}) for events "
            f"{self.time.start_date}..{self.time.end_date} "
            f"min_magnitude={min_magnitude}"
        )
        try:
            catalog = client.get_events(
                starttime=UTCDateTime(self.time.start_date),
                endtime=UTCDateTime(self.time.end_date),
                minlatitude=self.space.south,
                maxlatitude=self.space.north,
                minlongitude=self.space.west,
                maxlongitude=self.space.east,
                minmagnitude=min_magnitude,
                maxmagnitude=self._max_magnitude,
                mindepth=self._min_depth,
                maxdepth=self._max_depth,
                magnitudetype=self._magnitude_type,
                eventtype=self._event_type,
                orderby=self._orderby,
                limit=self._request_limit,
            )
        except FDSNNoDataException:
            logger.info(
                f"FDSN provider {provider_key!r} returned no events for the "
                "requested window — empty result."
            )
            return events.empty_fc()
        return events.catalog_to_fc(catalog, provider_key)

    def download(
        self,
        progress_bar: bool = True,
        errors: str = "warn",
        limit: int | None = None,
        force: bool = False,
    ) -> FeatureCollection:
        """Query every requested network and return the unioned events.

        Each network in `self.vars` is queried once; its events are
        written to one vector file under `path` (named after the
        network), and the per-network results are concatenated into the
        single :class:`FeatureCollection` returned. A network that
        errors is logged and skipped (its events are simply absent from
        the union) rather than aborting the whole request; only a total
        failure — every network errored — raises. An all-empty result
        (every network matched nothing) returns a schema-correct empty
        FeatureCollection and writes nothing.

        When the instance was built with `with_shakemap=True`, each USGS
        event additionally gets its ShakeMap grids written as GeoTIFFs
        under `path/shakemap/<event>/`. Those rasters are a side effect
        only — the return value stays the event FeatureCollection. An
        event whose ShakeMap cannot be fetched is logged and skipped
        under the same `errors=` policy as a failed network, so one
        missing grid never costs the event table.

        Args:
            progress_bar: Whether to show a progress bar. obspy's
                `get_events` has none, so this only governs the ShakeMap
                loop, which is the long part of a `with_shakemap=True`
                call; a plain event query ignores it.
            errors: Partial-failure policy, applied to **both** the
                per-network query loop and the per-event ShakeMap loop.
                `"warn"` (the default) logs each failure and continues,
                `"ignore"` continues silently, and `"raise"` propagates
                the first failure. Note what `"raise"` costs on a
                `with_shakemap=True` call: the events have already been
                fetched and written by then, but raising out of
                `download()` means they are not *returned*, so a single
                missing ShakeMap loses the whole in-memory event table.
                Leave it at `"warn"` unless a missing raster should
                genuinely abort the request.
            force: Refetch a ShakeMap whose GeoTIFFs are already on
                disk instead of reusing them. A `download()` argument,
                not a constructor one — `EarthLens(...).download(force=True)`,
                not `EarthLens(force=True, ...)`. The rasters are written
                atomically, so a present file is a finished one and the
                default reuse is safe; this is the escape hatch for a
                file damaged after the fact, or for picking up a
                revised ShakeMap for an event USGS has since updated.
            limit: Maximum events **per network**, overriding the constructor's
                `limit=` for this call. Same meaning as that one — pushed into
                the FDSN query itself, so a per-network server-side cap rather
                than a total across networks — and accepted here so it can be
                passed through `EarthLens(...).download(limit=...)` like the
                other bounded backends. `None` (the default) keeps whatever the
                constructor set — note that a value passed here is **sticky**:
                it replaces the constructor's for this and every later call on
                the same instance, as `force=` and `errors=` do not.

        Returns:
            FeatureCollection: The row-wise union of every requested
                network's events, CRS `EPSG:4326`. Empty (schema-only)
                when no network matched anything.

        Raises:
            RuntimeError: If **every** requested network's query failed
                (propagated from :meth:`_fetch`). A partial failure of
                the *network* loop does not raise — the healthy
                networks' events are returned. The ShakeMap loop is
                governed by the same `errors=` policy, so under the
                default `"warn"` a failed raster does not raise either;
                under `errors="raise"` it does, and because the raise
                escapes `download()` the event table is not returned
                even though its files are already on disk.
        """
        if limit is not None:
            self._request_limit = self.check_limit(limit)
        self._errors = self.check_errors_policy(errors)
        self._force = force
        products = self._search()
        collections = self._fetch(products) if products else []

        written: list[Path] = []
        for product, collection in zip(products, collections):
            if len(collection):
                written.append(self._write(product.id, collection))

        rasters: list[Path] = []
        if self._with_shakemap:
            # Before the concat: the per-network split is what says which
            # events came from USGS, and the union has thrown that away.
            try:
                rasters = self._download_shakemaps(
                    products, collections, progress_bar=progress_bar
                )
            finally:
                self._close_client()

        combined = events.concat_fcs(collections)
        # `concat_fcs` has copied every network's events, so the per-network
        # copies are dead weight from here on. This does not lower the *peak* —
        # that was reached inside the concat, with both sets live — but it stops
        # them being held through the summary, the return, and however long the
        # caller keeps the result.
        collections.clear()
        if written:
            # "available" rather than "written": a re-run reuses rasters that
            # were already on disk, and calling those written would overstate
            # what this call did.
            raster_note = (
                f" plus {len(rasters)} ShakeMap raster(s) available"
                if self._with_shakemap
                else ""
            )
            logger.info(
                f"FDSN download summary: {len(combined)} events across "
                f"{len(written)} file(s){raster_note} written to {self.root_dir}"
            )
        else:
            logger.warning(
                "FDSN download summary: no events matched the request, nothing written"
            )
        return combined

    def _client(self) -> HttpClient:
        """Return this instance's pooled ComCat client, built on first use.

        Only the ShakeMap path needs HTTP — the event query itself goes
        through obspy — so the client is built lazily and a request
        without `with_shakemap=True` never opens a session at all.

        Returns:
            HttpClient: The same instance on every later call.
        """
        if self._http is None:
            # ComCat is a single origin fetched once per event, so a dropped
            # connection is a normal event rather than a signal to give up on
            # the whole batch; retrying transport errors matches flodis/hanze.
            # The default user-agent is already `earthlens/{version}`.
            self._http = HttpClient(
                timeout=_COMCAT_TIMEOUT,
                min_interval=_COMCAT_MIN_INTERVAL,
                # ChunkedEncodingError is the one a multi-megabyte body actually hits:
                # the connection survives the handshake and dies mid-stream, which
                # is neither a ConnectionError nor a Timeout.
                retry_on_exceptions=(
                    requests.ConnectionError,
                    requests.Timeout,
                    requests.exceptions.ChunkedEncodingError,
                ),
            )
        return self._http

    def _close_client(self) -> None:
        """Release the pooled ComCat client, if one was ever built.

        `HttpClient` exposes no `close()` of its own — only `HttpRangeFile`
        does — so the underlying `requests.Session` is closed directly. A
        session left open holds its connection pool for the lifetime of the
        backend instance, which matters here because the ShakeMap client is
        built per call and used for a bounded burst.
        """
        if self._http is not None:
            # `session` is annotated `requests.Session`, but `HttpClient`
            # accepts any session-like object and `RequestsGet` — the
            # per-call adapter earthlens.testing swaps in as the default
            # transport — has no `close()`. Closing unconditionally therefore
            # fails against a perfectly valid transport, which is why this is
            # a probe rather than a direct call.
            closer = getattr(self._http.session, "close", None)
            if callable(closer):
                closer()
            self._http = None

    def _download_shakemaps(
        self,
        products: list[RemoteProduct],
        collections: list[FeatureCollection],
        progress_bar: bool = True,
    ) -> list[Path]:
        """Write the ShakeMap side-output for every USGS event fetched.

        ShakeMap is a USGS ComCat product with no counterpart in the FDSN
        event standard, so a non-USGS network that returned events is
        logged once and skipped rather than silently producing nothing.

        Args:
            products: The products from :meth:`_search`.
            collections: The per-product FeatureCollections from
                :meth:`_fetch`, still split by network.
            progress_bar: Whether each archive fetch shows a progress
                bar. This is the long part of a `with_shakemap=True`
                call, so it is the only place the flag does anything.

        Returns:
            list[Path]: Every GeoTIFF available for the requested
                events — those written by this call plus any reused
                from a previous run.
        """
        event_ids: list[str] = []
        for product, collection in zip(products, collections):
            if product.metadata.get("fdsn_id") != _helpers.COMCAT_PROVIDER:
                if len(collection):
                    logger.warning(
                        f"with_shakemap=True but {product.id!r} is not "
                        f"{_helpers.COMCAT_PROVIDER}: ShakeMap is a USGS ComCat "
                        "product, so this network contributes events but no "
                        "rasters."
                    )
                continue
            event_ids.extend(str(value) for value in collection["event_id"])
        # Two networks can report the same ComCat event, and a catalog can
        # repeat one; de-duplicated so the ceiling counts each event once.
        event_ids = list(dict.fromkeys(event_ids))

        if not event_ids:
            return []

        # Drop what can never succeed before anything is counted. An id that
        # yields no ComCat id, or one whose directory fails the containment
        # assertion, is not work waiting to be done — left in, it would sit at
        # the head of the queue spending budget on every run and starve the
        # events behind it.
        event_ids = [event_id for event_id in event_ids if self._is_fetchable(event_id)]
        if not event_ids:
            return []

        # The ceiling bounds *work*, not events. An event already satisfied on
        # disk costs nothing, so spending budget on it would stall a re-run at
        # the same place forever instead of letting it advance through the list.
        pending = [event_id for event_id in event_ids if not self._is_cached(event_id)]
        if len(pending) > self._max_shakemap_events:
            deferred = set(pending[self._max_shakemap_events :])
            logger.warning(
                f"with_shakemap=True needs {len(pending)} fetches, over the "
                f"max_shakemap_events={self._max_shakemap_events} ceiling: taking "
                f"the first {self._max_shakemap_events} and deferring "
                f"{len(deferred)}. Each fetch costs a request plus a "
                "multi-megabyte archive. Re-run to take the next batch, raise "
                "max_shakemap_events= to take them all at once, or narrow the "
                "query with limit= / min_magnitude=."
            )
            event_ids = [event_id for event_id in event_ids if event_id not in deferred]

        fetching = sum(1 for event_id in event_ids if not self._is_cached(event_id))
        logger.info(
            f"ShakeMap {list(self._shakemap_layers)}: {fetching} event(s) to fetch, "
            f"{len(event_ids) - fetching} already on disk"
        )
        # A per-archive bar is useful for a single fetch and unreadable for
        # fifty, so a batch reports through the log line above instead.
        per_archive_bar = progress_bar and len(event_ids) == 1
        results, _failed = self._run_items(
            event_ids,
            lambda event_id: self._shakemap_for_event(
                event_id, progress_bar=per_archive_bar
            ),
            errors=self._errors,
            label="ShakeMap",
            describe=repr,
            on_failure=lambda _event_id, _exc: [],
        )
        written = [path for paths in results for path in paths]
        shakemap_root = self.root_dir / _SHAKEMAP_DIR
        # An empty `shakemap/` reads as "asked for, produced nothing" only if it
        # is there at all; removing it keeps a fruitless run from looking like a
        # partial success.
        with suppress(OSError):
            if shakemap_root.is_dir() and not any(shakemap_root.iterdir()):
                shakemap_root.rmdir()
        return written

    def _record_manifest(
        self,
        dest_dir: Path,
        produced: list[str],
        product_version: str | None = None,
    ) -> None:
        """Record an event's outcome, warning rather than failing if it cannot.

        The manifest is a cache, not the deliverable: a raster already written
        must not be lost because its bookkeeping could not be saved. A failure
        is surfaced, though, because the only symptom otherwise is a re-run
        that silently refetches everything.

        Args:
            dest_dir: The event's output directory.
            produced: The layers the archive actually carried.
            product_version: The ShakeMap product's `updateTime`, if known.

        Returns:
            None: The manifest is written as a side effect; a failure is
                logged rather than raised.
        """
        try:
            _helpers.write_manifest(
                dest_dir,
                self._shakemap_layers,
                produced,
                checked=time.time(),
                product_version=product_version,
            )
        except OSError as error:
            logger.warning(
                f"could not record the ShakeMap manifest in {dest_dir}: {error}. "
                "The rasters are written, but a re-run will fetch them again."
            )

    def _event_dir(self, comcat_id: str) -> Path | None:
        """Resolve an event's output directory, refusing one that escapes.

        Args:
            comcat_id: The event's ComCat id.

        Returns:
            Path | None: The directory, or `None` when it does not resolve
                inside the ShakeMap root.
        """
        shakemap_root = self.root_dir / _SHAKEMAP_DIR
        dest_dir = shakemap_root / comcat_id
        # Defence in depth. `parse_comcat_id` already refuses an id that could
        # traverse, but the id comes from an upstream server and this directory
        # is deleted from, so the containment is asserted rather than assumed.
        if not dest_dir.resolve().is_relative_to(shakemap_root.resolve()):
            logger.warning(
                f"refusing ComCat id {comcat_id!r}: it does not resolve inside "
                f"{shakemap_root} — skipping its ShakeMap."
            )
            return None
        return dest_dir

    def _cached_rasters(self, dest_dir: Path) -> list[Path] | None:
        """Decide whether an event can be served from disk, without logging.

        Split from :meth:`_reuse_existing` so the fan-out ceiling can ask the
        same question about an event it may not process, without emitting a
        skip line for work it never started.

        Args:
            dest_dir: The event's output directory.

        Returns:
            list[Path] | None: The rasters to reuse — empty for an event known
                to publish none — or `None` when the event must be fetched.
        """
        if self._force:
            return None
        manifest = _helpers.read_manifest(dest_dir)
        if manifest is None:
            return None
        # A previous call may have asked for fewer layers than this one. Reuse
        # only when the earlier request covered everything now wanted.
        previously = set(manifest.get("requested", []))
        if not set(self._shakemap_layers).issubset(previously):
            return None
        recorded = set(manifest.get("produced", []))
        produced = [layer for layer in self._shakemap_layers if layer in recorded]
        missing = [layer for layer in self._shakemap_layers if layer not in recorded]
        if missing and self._record_is_stale(manifest):
            # Any requested layer the last run did not produce is a negative
            # result, and negative results age out — whether or not some *other*
            # requested layer happens to be on disk. Checking this only when the
            # intersection was empty meant a partially-published event cached its
            # missing grids permanently, which is exactly the case the manifest
            # was introduced for.
            return None
        cached = [dest_dir / f"{layer}.tif" for layer in produced]
        if not all(self._is_complete(path) for path in cached):
            return None
        if missing:
            logger.warning(
                f"ShakeMap layer(s) {missing} are not available for this event and "
                f"were last checked recently; returning {len(cached)} of "
                f"{len(self._shakemap_layers)} requested. Pass force=True to "
                "re-check now."
            )
        return cached

    @staticmethod
    def _record_is_stale(manifest: dict[str, object]) -> bool:
        """Report whether a manifest's negative half should be re-checked.

        Args:
            manifest: The parsed manifest.

        Returns:
            bool: `True` when the record is older than the negative-cache
                window, or carries a timestamp that cannot be trusted — a
                future one, or a non-finite one. An unusable clock reading
                fails towards refetching rather than towards caching an
                answer forever.
        """
        stamp = manifest.get("checked", 0.0)
        if not isinstance(stamp, (int, float)) or isinstance(stamp, bool):
            return True
        age = time.time() - float(stamp)
        # Written as a range test so a negative age (a future stamp) and a NaN
        # age (every comparison False) both come out stale.
        return not (0.0 <= age <= _NEGATIVE_CACHE_SECONDS)

    def _is_fetchable(self, event_id: str) -> bool:
        """Report whether an event could ever yield a ShakeMap.

        Separated from :meth:`_is_cached` because the two answer different
        questions: this one asks whether the event is *addressable* at all,
        and an event that is not can never become cached, so counting it as
        pending work would let it block the queue indefinitely.

        Args:
            event_id: The event's `event_id` column value.

        Returns:
            bool: `False` when no ComCat id can be parsed, or the resolved
                directory would escape the ShakeMap root.
        """
        comcat_id = _helpers.parse_comcat_id(event_id)
        if comcat_id is None:
            logger.warning(
                f"cannot resolve a ComCat id from event_id {event_id!r} — "
                "skipping its ShakeMap."
            )
            return False
        return self._event_dir(comcat_id) is not None

    def _is_cached(self, event_id: str) -> bool:
        """Report whether an event needs no work this run.

        Args:
            event_id: The event's `event_id` column value.

        Returns:
            bool: `True` when the event is already satisfied on disk, so the
                fan-out ceiling should not spend budget on it.
        """
        comcat_id = _helpers.parse_comcat_id(event_id)
        if comcat_id is None:
            return False
        dest_dir = self._event_dir(comcat_id)
        if dest_dir is None:
            return False
        return self._cached_rasters(dest_dir) is not None

    def _reuse_existing(self, dest_dir: Path, comcat_id: str) -> list[Path] | None:
        """Return an event's cached rasters, or `None` to fetch it again.

        Reuse is decided from the event's manifest rather than from the
        requested layer names, because an archive need not carry every
        layer that was asked for. Without the manifest, an event whose
        archive permanently lacks one requested grid could never satisfy
        an "all requested rasters present" check and would re-download
        its multi-megabyte archive on every single run.

        Args:
            dest_dir: The event's output directory.
            comcat_id: The event's ComCat id, for the log line.

        Returns:
            list[Path] | None: The rasters already on disk for this
                request — possibly empty, when the event publishes no
                ShakeMap at all — or `None` when the event must be
                fetched.
        """
        cached = self._cached_rasters(dest_dir)
        if cached is None:
            return None
        if cached:
            logger.info(f"ShakeMap for {comcat_id} already on disk — skipping.")
        else:
            logger.info(
                f"as of the last run, event {comcat_id} published no ShakeMap "
                "raster for the requested layer(s) — skipping."
            )
        return cached

    def _shakemap_for_event(
        self, event_id: str, progress_bar: bool = True
    ) -> list[Path]:
        """Fetch, unpack, and convert one event's requested ShakeMap grids.

        Skips the network when the event's manifest says this run's
        layers are already accounted for — either their GeoTIFFs are on
        disk, or the archive was checked recently and carries none of
        them. The skip is decided from what a previous run *recorded*,
        not from whether every requested file exists, because an archive
        need not carry every layer that was asked for.

        Everything the fetch and the conversion touch stays inside a
        staging directory that is removed wholesale, so no intermediate
        — the archive, the `.flt` / `.hdr` pairs, or the `.prj` GDAL
        writes when the CRS is assigned — reaches the output folder.

        Args:
            event_id: The event's `event_id` column value, a QuakeML
                resource identifier carrying the ComCat id.
            progress_bar: Whether the archive fetch shows a progress bar.

        Returns:
            list[Path]: The GeoTIFFs written for this event; empty when
                the identifier carries no ComCat id, the event has no
                ShakeMap product, or the archive lacked every requested
                grid.
        """
        comcat_id = _helpers.parse_comcat_id(event_id)
        if comcat_id is None:
            logger.warning(
                f"cannot resolve a ComCat id from event_id {event_id!r} — "
                "skipping its ShakeMap."
            )
            return []

        dest_dir = self._event_dir(comcat_id)
        if dest_dir is None:
            return []

        reused = self._reuse_existing(dest_dir, comcat_id)
        if reused is not None:
            return reused

        detail = self._client().get_json(_helpers.detail_url(comcat_id))
        url = _helpers.shakemap_raster_url(detail)
        if url is None:
            logger.info(
                f"event {comcat_id} publishes no ShakeMap raster — skipping it."
            )
            # Recorded so a re-run does not re-request this event's detail
            # document only to reach the same conclusion.
            self._record_manifest(dest_dir, [])
            return []

        # Everything the fetch and the conversion touch is confined to a staging
        # directory that is removed wholesale, rather than cleaned up by globbing
        # the user's output directory for suffixes. That keeps GDAL's own
        # by-products out of the result: opening the `EHdr` grid read-write to
        # assign its CRS makes the driver drop a `<layer>.prj` beside the `.flt`,
        # which a suffix-based sweep would leave behind in the output folder.
        staging = dest_dir / f"{_STAGING_PREFIX}-{os.getpid()}"
        archive = staging / "raster.zip"
        written: list[Path] = []
        try:
            staging.mkdir(parents=True, exist_ok=True)
            # `expect_magic` keeps an HTML error page served with a 200 from
            # landing as a .zip and failing later as a confusing bad-archive.
            self._client().download(
                url, archive, expect_magic=b"PK", progress=progress_bar
            )
            extracted = _helpers.extract_layers(archive, self._shakemap_layers, staging)
            for layer, flt_path in extracted.items():
                written.append(
                    _helpers.flt_to_geotiff(flt_path, dest_dir / f"{layer}.tif")
                )
            # Only after every conversion succeeded: a manifest written on a
            # partial failure would cache an incomplete result as final.
            self._record_manifest(
                dest_dir,
                list(extracted),
                product_version=_helpers.shakemap_product_version(detail),
            )
        finally:
            shutil.rmtree(staging, ignore_errors=True)
            if staging.exists():
                # Reported, not swallowed. On Windows this means something still
                # holds a handle on an extracted grid; silently leaving it would
                # accumulate one scratch directory per failed event inside the
                # user's output.
                logger.warning(
                    f"could not remove the ShakeMap staging directory {staging} — "
                    "it is left behind; remove it by hand if it persists."
                )
            # An event whose archive was unusable leaves no rasters, and an
            # empty directory named after it reads like a successful fetch.
            with suppress(OSError):
                if dest_dir.is_dir():
                    remaining = list(dest_dir.iterdir())
                    if not remaining:
                        dest_dir.rmdir()
                    elif all(
                        item.name.startswith(_STAGING_PREFIX) for item in remaining
                    ):
                        # Another process's scratch space, or one left by a run
                        # that died. Not ours to delete, but silence would let it
                        # accumulate unnoticed and keep this directory alive.
                        logger.warning(
                            f"{dest_dir} holds only orphaned scratch "
                            f"director(ies) {[item.name for item in remaining]} "
                            "from an interrupted run; remove them by hand."
                        )
        return written

    def _write(self, provider_key: str, collection: FeatureCollection) -> Path:
        """Write one network's events to a vector file under `root_dir`.

        Args:
            provider_key: The network key, used as the filename stem.
            collection: The network's events.

        Returns:
            Path: Absolute path of the file written.
        """
        driver, ext = _DRIVERS[self._file_format]
        out_path = self.root_dir / f"{provider_key.lower()}.{ext}"
        collection.to_file(str(out_path), driver=driver)
        return out_path

__init__(start, end, variables, lat_lim, lon_lim, temporal_resolution='all', path=None, fmt='%Y-%m-%d', min_magnitude=None, max_magnitude=None, min_depth=None, max_depth=None, magnitude_type=None, event_type=None, orderby='time', limit=None, earthscope_token=None, file_format='gpkg', with_shakemap=False, shakemap_layers=None, max_shakemap_events=_DEFAULT_MAX_SHAKEMAP_EVENTS) #

Initialise an FDSN backend instance.

Parameters:

Name Type Description Default
start str

Inclusive start of the event window, as a string parsed with fmt.

required
end str

Inclusive end of the event window.

required
variables list[str]

List of FDSN network keys to query (["USGS"], ["USGS", "EMSC"]). For this backend variables names the seismic networks, not data variables (see the package docstring). An empty list defaults to ["USGS"].

required
lat_lim list[float]

[lat_min, lat_max] bounding-box latitudes in degrees, both in [-90, 90].

required
lon_lim list[float]

[lon_min, lon_max] bounding-box longitudes in degrees, both in [-180, 180].

required
temporal_resolution str

FDSN does not chunk by day/month — the whole [start, end] window is one query — so this is the sentinel "all", not a pandas frequency alias.

'all'
path Path | str | None

Output directory for the per-network vector files. Created by the parent class if absent.

None
fmt str

strptime format for start / end.

'%Y-%m-%d'
min_magnitude float | None

Lower magnitude bound. None (the default) falls back per network to that provider's default_min_magnitude catalog value (USGS / EMSC / EarthScope / ISC = 4.5, INGV = 2.0, GeoNet = 3.0), so each regional network keeps a sensible floor. Pass an explicit number to override every network with one bound.

None
max_magnitude float | None

Upper magnitude bound, or None.

None
min_depth float | None

Lower depth bound in kilometres, or None.

None
max_depth float | None

Upper depth bound in kilometres, or None.

None
magnitude_type str | None

Restrict to a magnitude type (e.g. "Mw"), or None for any.

None
event_type str | None

Restrict to an event type (e.g. "earthquake", "volcanic eruption"), or None.

None
orderby str

Result ordering — "time", "time-asc", "magnitude", or "magnitude-asc".

'time'
limit int | None

Maximum number of events per network, or None for no cap. Unlike the total-row limit= the tabular backends take, this is pushed into the FDSN query itself, so a capped request never transfers the events past the cap; with several networks the totals add up rather than being one overall ceiling. Rejected if zero or negative.

None
earthscope_token str | None

Optional EarthScope access token; falls back to EARTHSCOPE_TOKEN / ~/.earthscope_token. Used only for a provider that requires a token.

None
file_format FileFormat

Output vector format — "gpkg" (default, GeoPackage) or "geojson".

'gpkg'
with_shakemap bool

Also fetch each event's gridded ShakeMap and write it as a GeoTIFF under path/shakemap/<event>/. USGS only — ShakeMap is a ComCat product, not part of the FDSN event standard, so a non-USGS network in the same request contributes events but no rasters. Costs one extra ComCat request plus a multi-megabyte archive per event, so bound the event count with limit= before turning it on for a busy window; beyond max_shakemap_events the side-output stops and says how many events it skipped. It does not change OUTPUT_KINDdownload() still returns the event FeatureCollection, and the rasters are a side effect.

False
max_shakemap_events int

Ceiling on how many events one call will fetch a ShakeMap for, guarding against a broad query quietly pulling gigabytes (each event is a separate request plus a multi-megabyte archive). Events past the ceiling are deferred with a warning naming the count — never silently. The ceiling counts fetches, not events: an event already satisfied on disk costs no budget, and an id that can never be fetched at all is dropped before counting. So re-running the same request takes the next batch, and repeating it eventually walks the whole list. The one exception is an event that fails on every attempt: a failure is not cached, so it is retried each run and keeps its place in the queue. Within a run the events kept are the first max_shakemap_events in the order the networks returned them, which orderby= controls ("magnitude" puts the largest first, the usual intent when capping). Raise the ceiling deliberately for a large job, or narrow the query with limit= / min_magnitude=.

_DEFAULT_MAX_SHAKEMAP_EVENTS
shakemap_layers list[str] | None

Which ShakeMap grids to write when with_shakemap is on. None (the default) writes ["mmi_mean"] — macroseismic intensity, the headline shaking field. The archive carries fourteen grids (mmi, pga, pgv, psa0p3, psa0p6, psa1p0, psa3p0, each _mean and _std); all are reachable, none but the default are written unless asked for. Ignored when with_shakemap is False.

None

Raises:

Type Description
ValueError

If file_format is not a supported vector format, if limit is zero or negative, or if shakemap_layers names a grid the archive does not carry.

TypeError

If variables is a mapping — for this backend it selects seismic networks, not data variables.

Source code in libs/providers/hazards/src/earthlens/fdsn/backend.py
def __init__(
    self,
    start: str,
    end: str,
    variables: list[str],
    lat_lim: list[float],
    lon_lim: list[float],
    temporal_resolution: str = "all",
    path: Path | str | None = None,
    fmt: str = "%Y-%m-%d",
    min_magnitude: float | None = None,
    max_magnitude: float | None = None,
    min_depth: float | None = None,
    max_depth: float | None = None,
    magnitude_type: str | None = None,
    event_type: str | None = None,
    orderby: str = "time",
    limit: int | None = None,
    earthscope_token: str | None = None,
    file_format: FileFormat = "gpkg",
    with_shakemap: bool = False,
    shakemap_layers: list[str] | None = None,
    max_shakemap_events: int = _DEFAULT_MAX_SHAKEMAP_EVENTS,
):
    """Initialise an FDSN backend instance.

    Args:
        start: Inclusive start of the event window, as a string
            parsed with `fmt`.
        end: Inclusive end of the event window.
        variables: List of FDSN network keys to query (`["USGS"]`,
            `["USGS", "EMSC"]`). For this backend `variables`
            names the seismic *networks*, not data variables (see
            the package docstring). An empty list defaults to
            `["USGS"]`.
        lat_lim: `[lat_min, lat_max]` bounding-box latitudes in
            degrees, both in `[-90, 90]`.
        lon_lim: `[lon_min, lon_max]` bounding-box longitudes in
            degrees, both in `[-180, 180]`.
        temporal_resolution: FDSN does not chunk by day/month — the
            whole `[start, end]` window is one query — so this is
            the sentinel `"all"`, not a pandas frequency alias.
        path: Output directory for the per-network vector files.
            Created by the parent class if absent.
        fmt: `strptime` format for `start` / `end`.
        min_magnitude: Lower magnitude bound. `None` (the default)
            falls back per network to that provider's
            `default_min_magnitude` catalog value (USGS / EMSC /
            EarthScope / ISC = 4.5, INGV = 2.0, GeoNet = 3.0), so
            each regional network keeps a sensible floor. Pass an
            explicit number to override every network with one
            bound.
        max_magnitude: Upper magnitude bound, or `None`.
        min_depth: Lower depth bound in kilometres, or `None`.
        max_depth: Upper depth bound in kilometres, or `None`.
        magnitude_type: Restrict to a magnitude type (e.g.
            `"Mw"`), or `None` for any.
        event_type: Restrict to an event type (e.g.
            `"earthquake"`, `"volcanic eruption"`), or `None`.
        orderby: Result ordering — `"time"`, `"time-asc"`,
            `"magnitude"`, or `"magnitude-asc"`.
        limit: Maximum number of events **per network**, or `None` for no
            cap. Unlike the total-row `limit=` the tabular backends take,
            this is pushed into the FDSN query itself, so a capped request
            never transfers the events past the cap; with several networks
            the totals add up rather than being one overall ceiling.
            Rejected if zero or negative.
        earthscope_token: Optional EarthScope access token; falls
            back to `EARTHSCOPE_TOKEN` / `~/.earthscope_token`.
            Used only for a provider that requires a token.
        file_format: Output vector format — `"gpkg"` (default,
            GeoPackage) or `"geojson"`.
        with_shakemap: Also fetch each event's gridded ShakeMap and
            write it as a GeoTIFF under `path/shakemap/<event>/`.
            **USGS only** — ShakeMap is a ComCat product, not part
            of the FDSN event standard, so a non-USGS network in the
            same request contributes events but no rasters. Costs
            one extra ComCat request plus a multi-megabyte archive per
            event, so bound the event count with `limit=` before
            turning it on for a busy window; beyond
            `max_shakemap_events` the side-output stops and says how
            many events it skipped. It does not change
            `OUTPUT_KIND` — `download()` still returns the event
            FeatureCollection, and the rasters are a side effect.
        max_shakemap_events: Ceiling on how many events one call
            will fetch a ShakeMap for, guarding against a broad
            query quietly pulling gigabytes (each event is a
            separate request plus a multi-megabyte archive). Events past
            the ceiling are deferred with a warning naming the
            count — never silently. The ceiling counts *fetches*,
            not events: an event already satisfied on disk costs no
            budget, and an id that can never be fetched at all is
            dropped before counting. So re-running the same request
            takes the next batch, and repeating it eventually walks
            the whole list. The one exception is an event that fails
            on every attempt: a failure is not cached, so it is
            retried each run and keeps its place in the queue.
            Within a run the events kept are the first
            `max_shakemap_events` in the order the networks returned
            them, which `orderby=` controls (`"magnitude"` puts the
            largest first, the usual intent when capping). Raise the
            ceiling deliberately for a large job, or narrow the
            query with `limit=` / `min_magnitude=`.
        shakemap_layers: Which ShakeMap grids to write when
            `with_shakemap` is on. `None` (the default) writes
            `["mmi_mean"]` — macroseismic intensity, the headline
            shaking field. The archive carries fourteen grids
            (`mmi`, `pga`, `pgv`, `psa0p3`, `psa0p6`, `psa1p0`,
            `psa3p0`, each `_mean` and `_std`); all are reachable,
            none but the default are written unless asked for.
            Ignored when `with_shakemap` is `False`.

    Raises:
        ValueError: If `file_format` is not a supported vector
            format, if `limit` is zero or negative, or if
            `shakemap_layers` names a grid the archive does not
            carry.
        TypeError: If `variables` is a mapping — for this backend it
            selects seismic networks, not data variables.
    """
    self._min_magnitude = min_magnitude
    self._max_magnitude = max_magnitude
    self._min_depth = min_depth
    self._max_depth = max_depth
    self._magnitude_type = magnitude_type
    self._event_type = event_type
    self._orderby = orderby
    # Not `self._limit`: the base class owns that name for the
    # client-side total cap, and a provider storing its own meaning there
    # is how usgs_water silently lost its server-side limit. Validated
    # here so a zero/negative cap is refused before it reaches the FDSN
    # query, where it would be a server-side argument whose meaning varies
    # by provider rather than an obvious client-side bug.
    self._request_limit = self.check_limit(limit)
    self._earthscope_token_arg = earthscope_token
    self._earthscope_token: str | None = None
    if file_format not in _DRIVERS:
        raise ValueError(
            f"file_format must be one of {sorted(_DRIVERS)}, got {file_format!r}."
        )
    self._file_format: FileFormat = file_format
    self._with_shakemap = bool(with_shakemap)
    # Validated even when the flag is off, so a typo'd layer name is a
    # construction-time error rather than a surprise the day someone
    # turns the flag on.
    self._shakemap_layers = _helpers.normalize_layers(shakemap_layers)
    # Validated inline rather than through `check_limit`, which is typed
    # for an optional cap: this one is always an int, and keeping it that
    # way lets the ceiling comparison stay a plain `>`.
    if not isinstance(max_shakemap_events, int) or isinstance(
        max_shakemap_events, bool
    ):
        raise TypeError(
            "max_shakemap_events must be an integer, got "
            f"{type(max_shakemap_events).__name__}."
        )
    if max_shakemap_events <= 0:
        raise ValueError(
            "max_shakemap_events must be a positive integer, got "
            f"{max_shakemap_events!r}."
        )
    self._max_shakemap_events: int = max_shakemap_events
    self._http: HttpClient | None = None
    if isinstance(variables, dict):
        raise TypeError(
            "FDSN `variables` must be a list of network keys (e.g. "
            "['USGS', 'EMSC']), not a mapping. For this backend "
            "`variables` selects seismic networks, not data variables; "
            "query filters are explicit FDSN(...) keyword arguments."
        )
    self._catalog = Catalog()
    super().__init__(
        start=start,
        end=end,
        variables=list(variables) or list(_DEFAULT_PROVIDERS),
        temporal_resolution=temporal_resolution,
        lat_lim=lat_lim,
        lon_lim=lon_lim,
        fmt=fmt,
        path=path,
    )

download(progress_bar=True, errors='warn', limit=None, force=False) #

Query every requested network and return the unioned events.

Each network in self.vars is queried once; its events are written to one vector file under path (named after the network), and the per-network results are concatenated into the single :class:FeatureCollection returned. A network that errors is logged and skipped (its events are simply absent from the union) rather than aborting the whole request; only a total failure — every network errored — raises. An all-empty result (every network matched nothing) returns a schema-correct empty FeatureCollection and writes nothing.

When the instance was built with with_shakemap=True, each USGS event additionally gets its ShakeMap grids written as GeoTIFFs under path/shakemap/<event>/. Those rasters are a side effect only — the return value stays the event FeatureCollection. An event whose ShakeMap cannot be fetched is logged and skipped under the same errors= policy as a failed network, so one missing grid never costs the event table.

Parameters:

Name Type Description Default
progress_bar bool

Whether to show a progress bar. obspy's get_events has none, so this only governs the ShakeMap loop, which is the long part of a with_shakemap=True call; a plain event query ignores it.

True
errors str

Partial-failure policy, applied to both the per-network query loop and the per-event ShakeMap loop. "warn" (the default) logs each failure and continues, "ignore" continues silently, and "raise" propagates the first failure. Note what "raise" costs on a with_shakemap=True call: the events have already been fetched and written by then, but raising out of download() means they are not returned, so a single missing ShakeMap loses the whole in-memory event table. Leave it at "warn" unless a missing raster should genuinely abort the request.

'warn'
force bool

Refetch a ShakeMap whose GeoTIFFs are already on disk instead of reusing them. A download() argument, not a constructor one — EarthLens(...).download(force=True), not EarthLens(force=True, ...). The rasters are written atomically, so a present file is a finished one and the default reuse is safe; this is the escape hatch for a file damaged after the fact, or for picking up a revised ShakeMap for an event USGS has since updated.

False
limit int | None

Maximum events per network, overriding the constructor's limit= for this call. Same meaning as that one — pushed into the FDSN query itself, so a per-network server-side cap rather than a total across networks — and accepted here so it can be passed through EarthLens(...).download(limit=...) like the other bounded backends. None (the default) keeps whatever the constructor set — note that a value passed here is sticky: it replaces the constructor's for this and every later call on the same instance, as force= and errors= do not.

None

Returns:

Name Type Description
FeatureCollection FeatureCollection

The row-wise union of every requested network's events, CRS EPSG:4326. Empty (schema-only) when no network matched anything.

Raises:

Type Description
RuntimeError

If every requested network's query failed (propagated from :meth:_fetch). A partial failure of the network loop does not raise — the healthy networks' events are returned. The ShakeMap loop is governed by the same errors= policy, so under the default "warn" a failed raster does not raise either; under errors="raise" it does, and because the raise escapes download() the event table is not returned even though its files are already on disk.

Source code in libs/providers/hazards/src/earthlens/fdsn/backend.py
def download(
    self,
    progress_bar: bool = True,
    errors: str = "warn",
    limit: int | None = None,
    force: bool = False,
) -> FeatureCollection:
    """Query every requested network and return the unioned events.

    Each network in `self.vars` is queried once; its events are
    written to one vector file under `path` (named after the
    network), and the per-network results are concatenated into the
    single :class:`FeatureCollection` returned. A network that
    errors is logged and skipped (its events are simply absent from
    the union) rather than aborting the whole request; only a total
    failure — every network errored — raises. An all-empty result
    (every network matched nothing) returns a schema-correct empty
    FeatureCollection and writes nothing.

    When the instance was built with `with_shakemap=True`, each USGS
    event additionally gets its ShakeMap grids written as GeoTIFFs
    under `path/shakemap/<event>/`. Those rasters are a side effect
    only — the return value stays the event FeatureCollection. An
    event whose ShakeMap cannot be fetched is logged and skipped
    under the same `errors=` policy as a failed network, so one
    missing grid never costs the event table.

    Args:
        progress_bar: Whether to show a progress bar. obspy's
            `get_events` has none, so this only governs the ShakeMap
            loop, which is the long part of a `with_shakemap=True`
            call; a plain event query ignores it.
        errors: Partial-failure policy, applied to **both** the
            per-network query loop and the per-event ShakeMap loop.
            `"warn"` (the default) logs each failure and continues,
            `"ignore"` continues silently, and `"raise"` propagates
            the first failure. Note what `"raise"` costs on a
            `with_shakemap=True` call: the events have already been
            fetched and written by then, but raising out of
            `download()` means they are not *returned*, so a single
            missing ShakeMap loses the whole in-memory event table.
            Leave it at `"warn"` unless a missing raster should
            genuinely abort the request.
        force: Refetch a ShakeMap whose GeoTIFFs are already on
            disk instead of reusing them. A `download()` argument,
            not a constructor one — `EarthLens(...).download(force=True)`,
            not `EarthLens(force=True, ...)`. The rasters are written
            atomically, so a present file is a finished one and the
            default reuse is safe; this is the escape hatch for a
            file damaged after the fact, or for picking up a
            revised ShakeMap for an event USGS has since updated.
        limit: Maximum events **per network**, overriding the constructor's
            `limit=` for this call. Same meaning as that one — pushed into
            the FDSN query itself, so a per-network server-side cap rather
            than a total across networks — and accepted here so it can be
            passed through `EarthLens(...).download(limit=...)` like the
            other bounded backends. `None` (the default) keeps whatever the
            constructor set — note that a value passed here is **sticky**:
            it replaces the constructor's for this and every later call on
            the same instance, as `force=` and `errors=` do not.

    Returns:
        FeatureCollection: The row-wise union of every requested
            network's events, CRS `EPSG:4326`. Empty (schema-only)
            when no network matched anything.

    Raises:
        RuntimeError: If **every** requested network's query failed
            (propagated from :meth:`_fetch`). A partial failure of
            the *network* loop does not raise — the healthy
            networks' events are returned. The ShakeMap loop is
            governed by the same `errors=` policy, so under the
            default `"warn"` a failed raster does not raise either;
            under `errors="raise"` it does, and because the raise
            escapes `download()` the event table is not returned
            even though its files are already on disk.
    """
    if limit is not None:
        self._request_limit = self.check_limit(limit)
    self._errors = self.check_errors_policy(errors)
    self._force = force
    products = self._search()
    collections = self._fetch(products) if products else []

    written: list[Path] = []
    for product, collection in zip(products, collections):
        if len(collection):
            written.append(self._write(product.id, collection))

    rasters: list[Path] = []
    if self._with_shakemap:
        # Before the concat: the per-network split is what says which
        # events came from USGS, and the union has thrown that away.
        try:
            rasters = self._download_shakemaps(
                products, collections, progress_bar=progress_bar
            )
        finally:
            self._close_client()

    combined = events.concat_fcs(collections)
    # `concat_fcs` has copied every network's events, so the per-network
    # copies are dead weight from here on. This does not lower the *peak* —
    # that was reached inside the concat, with both sets live — but it stops
    # them being held through the summary, the return, and however long the
    # caller keeps the result.
    collections.clear()
    if written:
        # "available" rather than "written": a re-run reuses rasters that
        # were already on disk, and calling those written would overstate
        # what this call did.
        raster_note = (
            f" plus {len(rasters)} ShakeMap raster(s) available"
            if self._with_shakemap
            else ""
        )
        logger.info(
            f"FDSN download summary: {len(combined)} events across "
            f"{len(written)} file(s){raster_note} written to {self.root_dir}"
        )
    else:
        logger.warning(
            "FDSN download summary: no events matched the request, nothing written"
        )
    return combined

earthlens.fdsn.catalog #

Provider dispatch table for the FDSN seismic-event backend.

FDSN is a fixed query protocol, not a curated dataset catalogue, so this "catalog" is deliberately tiny: a six-row map from a user-facing network name ("USGS", "EMSC", "INGV", "EARTHSCOPE", "ISC", "GEONET") to the obspy URL_MAPPINGS key plus a little metadata. Adding another network later (ORFEUS, GFZ, …) is a hand-edit of one YAML row. There is no probe handler and no tools/fdsn/ directory, but the backend does ship refresh and validate handlers in earthlens.fdsn.cli: refresh lists the data centres obspy can reach (its URL_MAPPINGS registry, no network call) and diffs them against the fdsn_id values curated here, so a centre obspy gains or drops surfaces instead of going unnoticed. audit works too, derived from the refresher's drift axis rather than from a handler of its own.

:class:Catalog is a thin :class:earthlens.base.AbstractCatalog subclass. To stay consistent with the other backends (GEE / ECMWF / CMEMS), the network rows are stored in the framework's :attr:~earthlens.base.AbstractCatalog.datasets field — so the inherited dict-like surface works (len(cat), name in cat, cat[name], iter(cat), str(cat), :meth:get_dataset). Because the FDSN domain term for a row is a network / provider, the same map is mirrored onto :attr:~earthlens.base.AbstractCatalog.providers and :meth:get_provider works too — they are aliases of the same rows. Parsing is cached on (path, mtime) exactly like the other backends (see :data:_CATALOG_CACHE / :func:clear_catalog_cache). :data:CATALOG_PATH is the path to the bundled YAML and is monkey-patchable in tests.

Catalog #

Bases: AbstractCatalog

Provider catalog for the FDSN seismic-event backend.

Reads the bundled fdsn_data_catalog.yaml (shipped as package data) and exposes its providers: block as a map of :class:Provider rows. Instantiate with no arguments (Catalog()); :func:model_post_init loads and validates the YAML (cached) in one pass.

The rows live in the framework's :attr:datasets field so the inherited dict-like surface behaves like every other backend's catalog — len(cat), name in cat, cat[name], iter(cat) and :meth:get_dataset all work. The same rows are mirrored onto :attr:providers, so the domain-friendly :meth:get_provider and cat.providers work too; the two are aliases of one another.

Attributes:

Name Type Description
datasets dict[str, Provider]

Map from the user-facing network name to its :class:Provider dispatch row (the framework field).

providers dict[str, Provider]

Alias of :attr:datasets — same rows, kept for the FDSN "network / provider" vocabulary.

Examples:

  • The dict-like surface works like the other backends:
    >>> from earthlens.fdsn import Catalog
    >>> cat = Catalog()
    >>> len(cat)
    6
    >>> "USGS" in cat
    True
    >>> cat["USGS"].fdsn_id
    'USGS'
    
  • datasets and providers are the same rows; resolve via either accessor:
    >>> from earthlens.fdsn import Catalog
    >>> cat = Catalog()
    >>> sorted(cat.providers) == sorted(cat.datasets)
    True
    >>> cat.get_provider("USGS").fdsn_id
    'USGS'
    >>> cat.get_dataset("EMSC").fdsn_id
    'EMSC'
    
  • An unknown network raises with a did-you-mean hint:
    >>> from earthlens.fdsn import Catalog
    >>> Catalog().get_provider("USG")
    Traceback (most recent call last):
        ...
    ValueError: 'USG' is not a registered provider. Known providers: ['EARTHSCOPE', 'EMSC', 'GEONET', 'INGV', 'ISC', 'USGS']. Did you mean 'USGS'?
    
Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
class Catalog(AbstractCatalog):
    """Provider catalog for the FDSN seismic-event backend.

    Reads the bundled `fdsn_data_catalog.yaml` (shipped as package
    data) and exposes its `providers:` block as a map of
    :class:`Provider` rows. Instantiate with no arguments
    (`Catalog()`); :func:`model_post_init` loads and validates the YAML
    (cached) in one pass.

    The rows live in the framework's :attr:`datasets` field so the
    inherited dict-like surface behaves like every other backend's
    catalog — `len(cat)`, `name in cat`, `cat[name]`, `iter(cat)` and
    :meth:`get_dataset` all work. The same rows are mirrored onto
    :attr:`providers`, so the domain-friendly :meth:`get_provider` and
    `cat.providers` work too; the two are aliases of one another.

    Attributes:
        datasets: Map from the user-facing network name to its
            :class:`Provider` dispatch row (the framework field).
        providers: Alias of :attr:`datasets` — same rows, kept for the
            FDSN "network / provider" vocabulary.

    Examples:
        - The dict-like surface works like the other backends:
            ```python
            >>> from earthlens.fdsn import Catalog
            >>> cat = Catalog()
            >>> len(cat)
            6
            >>> "USGS" in cat
            True
            >>> cat["USGS"].fdsn_id
            'USGS'

            ```
        - `datasets` and `providers` are the same rows; resolve via
          either accessor:
            ```python
            >>> from earthlens.fdsn import Catalog
            >>> cat = Catalog()
            >>> sorted(cat.providers) == sorted(cat.datasets)
            True
            >>> cat.get_provider("USGS").fdsn_id
            'USGS'
            >>> cat.get_dataset("EMSC").fdsn_id
            'EMSC'

            ```
        - An unknown network raises with a did-you-mean hint:
            ```python
            >>> from earthlens.fdsn import Catalog
            >>> Catalog().get_provider("USG")
            Traceback (most recent call last):
                ...
            ValueError: 'USG' is not a registered provider. Known providers: ['EARTHSCOPE', 'EMSC', 'GEONET', 'INGV', 'ISC', 'USGS']. Did you mean 'USGS'?

            ```
    """

    _catalog_kind: str = "FDSN catalog"
    _entry_noun: str = "networks"

    datasets: dict[str, Provider] = Field(default_factory=dict)
    providers: dict[str, Provider] = Field(default_factory=dict)

    def model_post_init(self, __context: Any) -> None:
        """Auto-load the bundled catalog and keep `datasets`/`providers` in sync.

        `Catalog()` with no args reads :data:`CATALOG_PATH` (cached).
        Passing either `datasets=...` or `providers=...` skips the disk
        read (used in tests); whichever was supplied is mirrored onto
        the other so both accessors stay consistent.

        Raises:
            ValueError: Propagated from :func:`_load_catalog_data` when
                the YAML is missing, empty, or has a malformed row.
        """
        if self.datasets or self.providers:
            if not self.datasets:
                self.datasets = self.providers
            if not self.providers:
                self.providers = self.datasets
        else:
            rows = _load_catalog_data(CATALOG_PATH)
            self.datasets = dict(rows)
            self.providers = self.datasets
        super().model_post_init(__context)

    @classmethod
    def load(cls, catalog_path: Path | None = None) -> Catalog:
        """Read the FDSN provider catalog from disk (cached).

        Args:
            catalog_path: Path to the catalog YAML. Defaults to the
                module-level :data:`CATALOG_PATH`.

        Returns:
            A fully-populated :class:`Catalog`.

        Raises:
            ValueError: If the file has no `providers:` block, or a row
                fails :class:`Provider` validation.
        """
        catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
        rows = _load_catalog_data(catalog_path)
        return cls(datasets=dict(rows))

    def get_catalog(self) -> dict[str, Provider]:
        """Return the network map (satisfies the abstract contract).

        Returns:
            dict[str, Provider]: Same object as :attr:`datasets`.
        """
        return self.datasets

get_catalog() #

Return the network map (satisfies the abstract contract).

Returns:

Type Description
dict[str, Provider]

dict[str, Provider]: Same object as :attr:datasets.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
def get_catalog(self) -> dict[str, Provider]:
    """Return the network map (satisfies the abstract contract).

    Returns:
        dict[str, Provider]: Same object as :attr:`datasets`.
    """
    return self.datasets

load(catalog_path=None) classmethod #

Read the FDSN provider catalog from disk (cached).

Parameters:

Name Type Description Default
catalog_path Path | None

Path to the catalog YAML. Defaults to the module-level :data:CATALOG_PATH.

None

Returns:

Type Description
Catalog

A fully-populated :class:Catalog.

Raises:

Type Description
ValueError

If the file has no providers: block, or a row fails :class:Provider validation.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
@classmethod
def load(cls, catalog_path: Path | None = None) -> Catalog:
    """Read the FDSN provider catalog from disk (cached).

    Args:
        catalog_path: Path to the catalog YAML. Defaults to the
            module-level :data:`CATALOG_PATH`.

    Returns:
        A fully-populated :class:`Catalog`.

    Raises:
        ValueError: If the file has no `providers:` block, or a row
            fails :class:`Provider` validation.
    """
    catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
    rows = _load_catalog_data(catalog_path)
    return cls(datasets=dict(rows))

model_post_init(__context) #

Auto-load the bundled catalog and keep datasets/providers in sync.

Catalog() with no args reads :data:CATALOG_PATH (cached). Passing either datasets=... or providers=... skips the disk read (used in tests); whichever was supplied is mirrored onto the other so both accessors stay consistent.

Raises:

Type Description
ValueError

Propagated from :func:_load_catalog_data when the YAML is missing, empty, or has a malformed row.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
def model_post_init(self, __context: Any) -> None:
    """Auto-load the bundled catalog and keep `datasets`/`providers` in sync.

    `Catalog()` with no args reads :data:`CATALOG_PATH` (cached).
    Passing either `datasets=...` or `providers=...` skips the disk
    read (used in tests); whichever was supplied is mirrored onto
    the other so both accessors stay consistent.

    Raises:
        ValueError: Propagated from :func:`_load_catalog_data` when
            the YAML is missing, empty, or has a malformed row.
    """
    if self.datasets or self.providers:
        if not self.datasets:
            self.datasets = self.providers
        if not self.providers:
            self.providers = self.datasets
    else:
        rows = _load_catalog_data(CATALOG_PATH)
        self.datasets = dict(rows)
        self.providers = self.datasets
    super().model_post_init(__context)

Provider #

Bases: BaseModel

One FDSN-event network's dispatch row.

The user-facing name is the parent key in :attr:Catalog.providers (and :attr:Catalog.datasets) and is not stored on the row.

Attributes:

Name Type Description
fdsn_id str

obspy URL_MAPPINGS key (e.g. "USGS") or an explicit base URL the obspy.clients.fdsn.Client constructor accepts. This is what actually selects the web service.

title str

Human-readable description used in logs and docs.

needs_token bool

Whether this network's event service requires an access token. False for the public networks; the FDSN event endpoints are public, so this is informational and only consulted when a token has been supplied.

default_min_magnitude float

Network-appropriate default lower magnitude bound (regional networks like INGV report much smaller events than the global catalogs). Advisory — the backend's own min_magnitude kwarg takes precedence.

docs_url str

Link to the network's FDSN event-service documentation.

Examples:

  • Build a row directly:
    >>> from earthlens.fdsn import Provider
    >>> p = Provider(fdsn_id="USGS", title="USGS ComCat")
    >>> p.fdsn_id, p.needs_token, p.default_min_magnitude
    ('USGS', False, 4.5)
    
Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
class Provider(BaseModel):
    """One FDSN-event network's dispatch row.

    The user-facing name is the parent key in
    :attr:`Catalog.providers` (and :attr:`Catalog.datasets`) and is not
    stored on the row.

    Attributes:
        fdsn_id: obspy `URL_MAPPINGS` key (e.g. `"USGS"`) or an
            explicit base URL the `obspy.clients.fdsn.Client`
            constructor accepts. This is what actually selects the
            web service.
        title: Human-readable description used in logs and docs.
        needs_token: Whether this network's event service requires an
            access token. `False` for the public networks; the FDSN
            event endpoints are public, so this is informational and
            only consulted when a token has been supplied.
        default_min_magnitude: Network-appropriate default lower
            magnitude bound (regional networks like INGV report much
            smaller events than the global catalogs). Advisory — the
            backend's own `min_magnitude` kwarg takes precedence.
        docs_url: Link to the network's FDSN event-service
            documentation.

    Examples:
        - Build a row directly:
            ```python
            >>> from earthlens.fdsn import Provider
            >>> p = Provider(fdsn_id="USGS", title="USGS ComCat")
            >>> p.fdsn_id, p.needs_token, p.default_min_magnitude
            ('USGS', False, 4.5)

            ```
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    fdsn_id: str
    title: str = ""
    needs_token: bool = False
    default_min_magnitude: float = 4.5
    docs_url: str = ""

clear_catalog_cache() #

Empty the module-level catalog parse cache.

Useful in tests that rewrite the catalog on disk and want to force a re-parse. Production callers do not need this — the cache key includes the file's st_mtime_ns, so any real edit invalidates the entry on its own.

Source code in libs/providers/hazards/src/earthlens/fdsn/catalog.py
def clear_catalog_cache() -> None:
    """Empty the module-level catalog parse cache.

    Useful in tests that rewrite the catalog on disk and want to force
    a re-parse. Production callers do not need this — the cache key
    includes the file's `st_mtime_ns`, so any real edit invalidates the
    entry on its own.
    """
    _CATALOG_CACHE.clear()

earthlens.fdsn.events #

Map an obspy event catalog into a pyramids FeatureCollection.

This module is the only place in the FDSN backend that touches a GIS vector container, so per the pyramids policy it keeps all geometry/CRS handling inside pyramids primitives: earthlens assembles the plain attribute rows, builds a Point geometry column from the origin longitude/latitude, and hands the whole thing to :class:pyramids.feature.collection.FeatureCollection (a geopandas.GeoDataFrame subclass) tagged EPSG:4326.

The canonical event schema lives here as :data:ATTRIBUTE_COLUMNS (attribute columns + dtypes) plus the geometry column. Both the populated path (:func:catalog_to_fc) and the empty path (:func:empty_fc) produce a FeatureCollection with exactly these columns and dtypes, so a downstream pandas.concat / to_file never chokes on a schema mismatch between a hit and a miss.

The unit conventions follow the FDSN event standard as obspy exposes it: origin.depth is in metres (divided by 1000 for depth_km), origin.time is a UTCDateTime (converted to a tz-aware UTC datetime64), and preferred_origin() / preferred_magnitude() can return None, so the first list element is used as a fallback.

catalog_to_fc(catalog, provider) #

Convert an obspy Catalog into a FeatureCollection of events.

One row per event, columns per :data:ATTRIBUTE_COLUMNS plus a geometry column of shapely.Point(longitude, latitude) in EPSG:4326. An event with no usable origin (no preferred origin and an empty origins list) still contributes a row, but its geometry is None rather than an invalid POINT (nan nan) that would corrupt a written GeoPackage/GeoJSON. An empty catalog returns an empty FeatureCollection with the same columns/dtypes (see :func:empty_fc) so the result type is identical whether or not the query matched anything.

Parameters:

Name Type Description Default
catalog Catalog

An obspy.core.event.Catalog (iterable over Event), typically the return value of obspy.clients.fdsn.Client.get_events.

required
provider str

The user-facing provider key ("USGS", "EMSC", …) recorded in the provider column of every row.

required

Returns:

Name Type Description
FeatureCollection FeatureCollection

One feature per event, CRS EPSG:4326; rows whose event lacked a usable origin carry a null geometry.

Examples:

  • Map a one-event catalog and read back a field:
    >>> from obspy.core.event import Catalog, Event, Origin, Magnitude
    >>> from obspy import UTCDateTime
    >>> origin = Origin(
    ...     time=UTCDateTime("2024-01-01T00:00:00"),
    ...     longitude=12.5, latitude=42.0, depth=10000.0,
    ...     evaluation_status="reviewed",
    ... )
    >>> magnitude = Magnitude(mag=5.2, magnitude_type="Mw")
    >>> event = Event(
    ...     origins=[origin], magnitudes=[magnitude],
    ...     event_type="earthquake",
    ... )
    >>> from earthlens.fdsn.events import catalog_to_fc
    >>> fc = catalog_to_fc(Catalog(events=[event]), "USGS")
    >>> len(fc)
    1
    >>> float(fc["depth_km"].iloc[0])
    10.0
    >>> fc["provider"].iloc[0]
    'USGS'
    >>> fc.crs.to_epsg()
    4326
    
Source code in libs/providers/hazards/src/earthlens/fdsn/events.py
def catalog_to_fc(catalog: Catalog, provider: str) -> FeatureCollection:
    """Convert an obspy `Catalog` into a `FeatureCollection` of events.

    One row per event, columns per :data:`ATTRIBUTE_COLUMNS` plus a
    `geometry` column of `shapely.Point(longitude, latitude)` in
    `EPSG:4326`. An event with no usable origin (no preferred origin
    and an empty `origins` list) still contributes a row, but its
    `geometry` is `None` rather than an invalid `POINT (nan nan)` that
    would corrupt a written GeoPackage/GeoJSON. An empty catalog
    returns an empty FeatureCollection with the same columns/dtypes
    (see :func:`empty_fc`) so the result type is identical whether or
    not the query matched anything.

    Args:
        catalog: An `obspy.core.event.Catalog` (iterable over
            `Event`), typically the return value of
            `obspy.clients.fdsn.Client.get_events`.
        provider: The user-facing provider key (`"USGS"`, `"EMSC"`,
            …) recorded in the `provider` column of every row.

    Returns:
        FeatureCollection: One feature per event, CRS `EPSG:4326`;
            rows whose event lacked a usable origin carry a null
            geometry.

    Examples:
        - Map a one-event catalog and read back a field:
            ```python
            >>> from obspy.core.event import Catalog, Event, Origin, Magnitude
            >>> from obspy import UTCDateTime
            >>> origin = Origin(
            ...     time=UTCDateTime("2024-01-01T00:00:00"),
            ...     longitude=12.5, latitude=42.0, depth=10000.0,
            ...     evaluation_status="reviewed",
            ... )
            >>> magnitude = Magnitude(mag=5.2, magnitude_type="Mw")
            >>> event = Event(
            ...     origins=[origin], magnitudes=[magnitude],
            ...     event_type="earthquake",
            ... )
            >>> from earthlens.fdsn.events import catalog_to_fc
            >>> fc = catalog_to_fc(Catalog(events=[event]), "USGS")
            >>> len(fc)
            1
            >>> float(fc["depth_km"].iloc[0])
            10.0
            >>> fc["provider"].iloc[0]
            'USGS'
            >>> fc.crs.to_epsg()
            4326

            ```
    """
    rows = [_event_to_row(event, provider) for event in catalog]
    if not rows:
        return empty_fc()

    frame = pd.DataFrame(rows, columns=list(ATTRIBUTE_COLUMNS))
    frame["time"] = pd.to_datetime(frame["time"], utc=True)
    for column, dtype in ATTRIBUTE_COLUMNS.items():
        if column == "time":
            continue
        frame[column] = frame[column].astype(dtype)

    # Build the Point per row so an event with no usable origin (NaN
    # longitude/latitude) gets a null geometry rather than an invalid
    # POINT (nan nan) that would corrupt a written GeoPackage/GeoJSON.
    points = [
        Point(lon, lat) if pd.notna(lon) and pd.notna(lat) else None
        for lon, lat in zip(frame["longitude"], frame["latitude"])
    ]
    gdf = gpd.GeoDataFrame(
        frame, geometry=gpd.GeoSeries(points, crs=EVENT_CRS), crs=EVENT_CRS
    )
    return FeatureCollection(gdf)

concat_fcs(collections) #

Concatenate per-provider FeatureCollections into one.

Skips empty inputs; returns :func:empty_fc when every input is empty (or the list is empty) so the result is always a schema-correct FeatureCollection.

Parameters:

Name Type Description Default
collections list[FeatureCollection]

Per-provider FeatureCollections, each produced by :func:catalog_to_fc (so all share the schema and CRS).

required

Returns:

Name Type Description
FeatureCollection FeatureCollection

The row-wise union, CRS EPSG:4326.

Source code in libs/providers/hazards/src/earthlens/fdsn/events.py
def concat_fcs(collections: list[FeatureCollection]) -> FeatureCollection:
    """Concatenate per-provider FeatureCollections into one.

    Skips empty inputs; returns :func:`empty_fc` when every input is
    empty (or the list is empty) so the result is always a
    schema-correct FeatureCollection.

    Args:
        collections: Per-provider FeatureCollections, each produced by
            :func:`catalog_to_fc` (so all share the schema and CRS).

    Returns:
        FeatureCollection: The row-wise union, CRS `EPSG:4326`.
    """
    non_empty = [fc for fc in collections if len(fc)]
    if not non_empty:
        return empty_fc()
    merged = pd.concat(non_empty, ignore_index=True)
    return FeatureCollection(
        gpd.GeoDataFrame(merged, geometry="geometry", crs=EVENT_CRS)
    )

empty_fc() #

Return an empty FeatureCollection with the canonical event schema.

Used for a query that matched nothing (a quiet region/time, an HTTP-204 FDSNNoDataException) so callers always get the same columns and dtypes back regardless of hit count — an empty result is a legitimate answer, not an error.

Returns:

Name Type Description
FeatureCollection FeatureCollection

Zero rows, the :data:ATTRIBUTE_COLUMNS columns with their declared dtypes, an empty geometry column, CRS EPSG:4326.

Examples:

  • The schema is present even with no rows:
    >>> from earthlens.fdsn.events import empty_fc, ATTRIBUTE_COLUMNS
    >>> fc = empty_fc()
    >>> len(fc)
    0
    >>> set(ATTRIBUTE_COLUMNS).issubset(fc.columns)
    True
    >>> "geometry" in fc.columns
    True
    >>> fc.crs.to_epsg()
    4326
    
Source code in libs/providers/hazards/src/earthlens/fdsn/events.py
def empty_fc() -> FeatureCollection:
    """Return an empty `FeatureCollection` with the canonical event schema.

    Used for a query that matched nothing (a quiet region/time, an
    HTTP-204 `FDSNNoDataException`) so callers always get the same
    columns and dtypes back regardless of hit count — an empty result
    is a legitimate answer, not an error.

    Returns:
        FeatureCollection: Zero rows, the :data:`ATTRIBUTE_COLUMNS`
            columns with their declared dtypes, an empty `geometry`
            column, CRS `EPSG:4326`.

    Examples:
        - The schema is present even with no rows:
            ```python
            >>> from earthlens.fdsn.events import empty_fc, ATTRIBUTE_COLUMNS
            >>> fc = empty_fc()
            >>> len(fc)
            0
            >>> set(ATTRIBUTE_COLUMNS).issubset(fc.columns)
            True
            >>> "geometry" in fc.columns
            True
            >>> fc.crs.to_epsg()
            4326

            ```
    """
    frame = pd.DataFrame(
        {
            column: pd.Series([], dtype=dtype)
            for column, dtype in ATTRIBUTE_COLUMNS.items()
        }
    )
    gdf = gpd.GeoDataFrame(
        frame, geometry=gpd.GeoSeries([], crs=EVENT_CRS), crs=EVENT_CRS
    )
    return FeatureCollection(gdf)

earthlens.fdsn.auth #

Optional EarthScope token resolution for the FDSN backend.

The six FDSN-event networks earthlens ships with (USGS, EMSC, INGV, EarthScope, ISC, GeoNet) all expose public event web services, so the common path needs no credentials at all. This module is intentionally thin: it only resolves an optional EarthScope access token from, in priority order, an explicit argument, the EARTHSCOPE_TOKEN environment variable, or a ~/.earthscope_token file. The backend consults it only when a provider row declares needs_token: true, which none of the bundled public networks do.

There is deliberately no credentialed-login class here (unlike :class:earthlens.cmems.CmemsAuth / earthlens.gee.EarthEngineAuth) — event queries do not authenticate.

resolve_earthscope_token(token=None) #

Resolve an optional EarthScope access token.

Resolution order:

  1. The explicit token argument, if non-empty.
  2. The EARTHSCOPE_TOKEN environment variable.
  3. A ~/.earthscope_token file (first non-empty line).

Parameters:

Name Type Description Default
token str | None

An explicit token passed by the caller, or None.

None

Returns:

Type Description
str | None

The resolved token string, or None when no source supplies one (the normal case for the public event services).

Examples:

  • An explicit token wins and is returned unchanged:
    >>> from earthlens.fdsn.auth import resolve_earthscope_token
    >>> resolve_earthscope_token("example-token")
    'example-token'
    
  • With no argument, env var, or file, the result is None:
    >>> import os
    >>> from earthlens.fdsn.auth import resolve_earthscope_token
    >>> os.environ.pop("EARTHSCOPE_TOKEN", None)  # doctest: +SKIP
    >>> resolve_earthscope_token()  # doctest: +SKIP
    
Source code in libs/providers/hazards/src/earthlens/fdsn/auth.py
def resolve_earthscope_token(token: str | None = None) -> str | None:
    """Resolve an optional EarthScope access token.

    Resolution order:

    1. The explicit `token` argument, if non-empty.
    2. The `EARTHSCOPE_TOKEN` environment variable.
    3. A `~/.earthscope_token` file (first non-empty line).

    Args:
        token: An explicit token passed by the caller, or `None`.

    Returns:
        The resolved token string, or `None` when no source supplies
            one (the normal case for the public event services).

    Examples:
        - An explicit token wins and is returned unchanged:
            ```python
            >>> from earthlens.fdsn.auth import resolve_earthscope_token
            >>> resolve_earthscope_token("example-token")
            'example-token'

            ```
        - With no argument, env var, or file, the result is `None`:
            ```python
            >>> import os
            >>> from earthlens.fdsn.auth import resolve_earthscope_token
            >>> os.environ.pop("EARTHSCOPE_TOKEN", None)  # doctest: +SKIP
            >>> resolve_earthscope_token()  # doctest: +SKIP

            ```
    """
    if token:
        return token
    env_token = os.environ.get("EARTHSCOPE_TOKEN")
    if env_token:
        return env_token
    if EARTHSCOPE_TOKEN_FILE.is_file():
        for line in EARTHSCOPE_TOKEN_FILE.read_text(encoding="utf-8").splitlines():
            stripped = line.strip()
            if stripped:
                return stripped
    return None

earthlens.fdsn.cli #

Catalog-tooling handlers for the FDSN backend.

Registered with core's catalog-tooling commands through the earthlens.cli entry-point group (see earthlens._hazards_cli). The refresh axis is obspy's URL_MAPPINGS registry — the same source the catalog is curated from — so a diff surfaces data centres obspy has gained or dropped.

refresher(_catalog) #

List every FDSN provider id obspy can reach (SDK enum, no network).

Parameters:

Name Type Description Default
_catalog Any

The loaded FDSN Catalog (unused; obspy is the source).

required

Returns:

Type Description
dict[str, list[str]]

A single-group mapping {"fdsn": [sorted provider ids]}.

Source code in libs/providers/hazards/src/earthlens/fdsn/cli.py
def refresher(_catalog: Any) -> dict[str, list[str]]:
    """List every FDSN provider id obspy can reach (SDK enum, no network).

    Args:
        _catalog: The loaded FDSN `Catalog` (unused; obspy is the source).

    Returns:
        A single-group mapping `{"fdsn": [sorted provider ids]}`.
    """
    return {"fdsn": sorted(set(_provider_ids()))}

validator(catalog) #

Each FDSN network needs an fdsn_id.

Parameters:

Name Type Description Default
catalog Any

The loaded FDSN Catalog.

required

Returns:

Type Description
tuple[int, list[str]]

(checked, issues) — the entry count and any structural problems.

Source code in libs/providers/hazards/src/earthlens/fdsn/cli.py
def validator(catalog: Any) -> tuple[int, list[str]]:
    """Each FDSN network needs an fdsn_id.

    Args:
        catalog: The loaded FDSN `Catalog`.

    Returns:
        `(checked, issues)` — the entry count and any structural problems.
    """
    return lint(catalog, lambda k, r: require(k, r, ("fdsn_id",)))