Ana içeriğe geç

Processing API

selection_service.processing.criteria.ScoringWeights

Bases: BaseModel

Kullanıcı arayüzünden gelen ağırlıklar. Eğer kullanıcı belirtmezse Config.py'deki varsayılanları kullanır.

Source code in src/selection_service/processing/criteria.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class ScoringWeights(BaseModel):
    """
    Kullanıcı arayüzünden gelen ağırlıklar. 
    Eğer kullanıcı belirtmezse Config.py'deki varsayılanları kullanır.
    """
    # Config'deki her anahtar için dinamik alan oluşturuyoruz
    # (Burayı manuel de yazabilirsiniz ama Pydantic ile dinamik de yönetilebilir)
    magnitude: float = SCORING_MAP['magnitude']['weight']
    rjb: float = SCORING_MAP['rjb']['weight']
    rrup: float = SCORING_MAP['rrup']['weight']
    repi: float = SCORING_MAP['repi']['weight']
    vs30: float = SCORING_MAP['vs30']['weight']
    pga: float = SCORING_MAP['pga']['weight']
    pgv: float = SCORING_MAP['pgv']['weight']
    pgd: float = SCORING_MAP['pgd']['weight']
    t90: float = SCORING_MAP['t90']['weight']
    arias: float = SCORING_MAP['arias']['weight']
    depth: float = SCORING_MAP['depth']['weight']
    mechanism: float = SCORING_MAP['mechanism']['weight']

    def get_weight(self, key: str) -> float:
        return getattr(self, key, 0.0)

    @classmethod
    def from_preset(cls, name: str) -> "ScoringWeights":
        """Documented scoring presets for repeatable selection profiles."""
        try:
            preset = SCORING_PRESETS[name]
        except KeyError as exc:
            available = ", ".join(sorted(SCORING_PRESETS))
            raise ValueError(f"Unknown scoring preset '{name}'. Available: {available}") from exc
        return cls(**preset["weights"])

    @classmethod
    def preset_descriptions(cls) -> Dict[str, str]:
        """Return user-facing descriptions for all built-in scoring presets."""
        return {
            name: preset["description"]
            for name, preset in SCORING_PRESETS.items()
        }

from_preset(name) classmethod

Documented scoring presets for repeatable selection profiles.

Source code in src/selection_service/processing/criteria.py
43
44
45
46
47
48
49
50
51
@classmethod
def from_preset(cls, name: str) -> "ScoringWeights":
    """Documented scoring presets for repeatable selection profiles."""
    try:
        preset = SCORING_PRESETS[name]
    except KeyError as exc:
        available = ", ".join(sorted(SCORING_PRESETS))
        raise ValueError(f"Unknown scoring preset '{name}'. Available: {available}") from exc
    return cls(**preset["weights"])

preset_descriptions() classmethod

Return user-facing descriptions for all built-in scoring presets.

Source code in src/selection_service/processing/criteria.py
53
54
55
56
57
58
59
@classmethod
def preset_descriptions(cls) -> Dict[str, str]:
    """Return user-facing descriptions for all built-in scoring presets."""
    return {
        name: preset["description"]
        for name, preset in SCORING_PRESETS.items()
    }

selection_service.processing.criteria.SelectionConfig

Bases: BaseModel

Seçim konfigürasyonu.

Adım 4 — Pydantic BaseModel'e dönüştürme: Önceki kod @dataclass + Pydantic Field() karışımı kullanıyordu. Field(default_factory=...) yalnızca Pydantic modelleri için geçerlidir; @dataclass ile birlikte kullanıldığında runtime'da sessizce yanlış davranış üretir (Field nesnesi liste yerine field descriptor olarak kalır).

Pydantic BaseModel kullanmanın ek faydaları
  • Alan doğrulaması kolayca eklenebilir.
  • .model_dump() / .model_copy() hazır gelir.
  • ScoringWeights ve SearchCriteria ile tutarlı tip sistemi.
Source code in src/selection_service/processing/criteria.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class SelectionConfig(BaseModel):
    """Seçim konfigürasyonu.

    Adım 4 — Pydantic BaseModel'e dönüştürme:
      Önceki kod @dataclass + Pydantic Field() karışımı kullanıyordu.
      Field(default_factory=...) yalnızca Pydantic modelleri için geçerlidir;
      @dataclass ile birlikte kullanıldığında runtime'da sessizce yanlış
      davranış üretir (Field nesnesi liste yerine field descriptor olarak kalır).

    Pydantic BaseModel kullanmanın ek faydaları:
      - Alan doğrulaması kolayca eklenebilir.
      - .model_dump() / .model_copy() hazır gelir.
      - ScoringWeights ve SearchCriteria ile tutarlı tip sistemi.
    """

    design_code         : DesignCode
    num_records         : int       = 22
    max_per_station     : int       = 3
    max_per_event       : int       = 3
    min_score           : float     = 50.0
    required_components : List[str] = Field(default_factory=list)
    dedup               : DedupConfig = Field(default_factory=DedupConfig)

selection_service.processing.criteria.DedupConfig

Bases: BaseModel

Provider'lar arası tekrar tespiti ayarları (bkz. processing.dedup).

Attributes:

Name Type Description
enabled bool

False ise olay birleştirme ve tekrar kayıt elemesi yapılmaz; EVENT_GROUP yine de <PROVIDER>|<EVENT> olarak yazılır.

max_event_distance_km float

Aynı deprem sayılacak en büyük episantr mesafesi.

max_mag_diff float

Aynı deprem sayılacak en büyük büyüklük farkı.

max_station_distance_km float

Aynı kayıt sayılacak en büyük istasyon mesafesi.

prefer_provider Optional[str]

Tekrar kayıtlardan hangi provider'ınki korunsun ("AFAD" veya ProviderName.AFAD). None ise provider listesinde ilk sıradaki korunur.

Source code in src/selection_service/processing/criteria.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class DedupConfig(BaseModel):
    """Provider'lar arası tekrar tespiti ayarları (bkz. ``processing.dedup``).

    Attributes:
        enabled: False ise olay birleştirme ve tekrar kayıt elemesi yapılmaz;
            ``EVENT_GROUP`` yine de ``<PROVIDER>|<EVENT>`` olarak yazılır.
        max_event_distance_km: Aynı deprem sayılacak en büyük episantr mesafesi.
        max_mag_diff: Aynı deprem sayılacak en büyük büyüklük farkı.
        max_station_distance_km: Aynı kayıt sayılacak en büyük istasyon mesafesi.
        prefer_provider: Tekrar kayıtlardan hangi provider'ınki korunsun
            (``"AFAD"`` veya ``ProviderName.AFAD``). None ise provider listesinde
            ilk sıradaki korunur.
    """

    enabled: bool = True
    max_event_distance_km: float = Field(DEFAULT_MAX_EVENT_DISTANCE_KM, gt=0)
    max_mag_diff: float = Field(DEFAULT_MAX_MAG_DIFF, ge=0)
    max_station_distance_km: float = Field(DEFAULT_MAX_STATION_DISTANCE_KM, gt=0)
    prefer_provider: Optional[str] = None

    @field_validator("prefer_provider", mode="before")
    @classmethod
    def _normalize_provider(cls, value: Any) -> Optional[str]:
        if value is None:
            return None
        return str(getattr(value, "value", value)).strip().upper() or None

selection_service.processing.criteria.SearchCriteria

Bases: BaseModel

Arama kriterleri - Tüm sağlayıcılar için ortak kriterler

Source code in src/selection_service/processing/criteria.py
111
112
113
114
115
116
117
118
119
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
class SearchCriteria(BaseModel):
    """Arama kriterleri - Tüm sağlayıcılar için ortak kriterler"""
    start_date: str                          # from_date: Başlangıç tarihi (ISO format: "2023-02-06T01:16:00.000Z")  
    end_date: str                            # to_date: Bitiş tarihi (ISO format: "2023-02-06T01:18:41.000Z")
    min_magnitude: Optional[float] = None    # from_mw: Minimum Mw büyüklüğü
    max_magnitude: Optional[float] = None    # to_mw: Maksimum Mw büyüklüğü
    min_depth: Optional[float] = None        # min_depth: Minimum derinlik
    max_depth: Optional[float] = None        # max_depth: Maksimum derinlik
    station_code: Optional[str] = None       # station_code: İstasyon kodu
    network: Optional[str] = None            # network: Ağ bilgisi
    country: Optional[str] = None            # Ülke
    province: Optional[str] = None           # İl
    district: Optional[str] = None           # İlçe
    neighborhood: Optional[str] = None       # Mahalle
    min_latitude: Optional[float] = None     # Minimum enlem for box search
    max_latitude: Optional[float] = None     # Maksimum enlem for box search
    min_longitude: Optional[float] = None    # Minimum boylam for box search
    max_longitude: Optional[float] = None    # Maksimum boylam for box search
    circleLatitude: Optional[float] = None   # circleLatitude: for circle search
    circleLongitude: Optional[float] = None  # circleLongitude: for circle search
    circleRadius: Optional[float] = None     # circleRadius: for circle search
    min_pga: Optional[float] = None          # Minimum PGA değeri
    max_pga: Optional[float] = None          # Maksimum PGA değeri
    min_pgv: Optional[float] = None          # Minimum PGV değeri
    max_pgv: Optional[float] = None          # Maksimum PGV değeri
    min_pgd: Optional[float] = None          # Minimum PGD değeri
    max_pgd: Optional[float] = None          # Maksimum PGD değeri
    fault_type: Optional[str] = None         # Fay tipi
    event_name: Optional[str] = None         # Event ismi
    min_Repi: Optional[float] = None         # Minimum Repi değeri Repicentral distance (Deprem merkez üssüne olan uzaklık) 
    max_Repi: Optional[float] = None         # Maksimum Repi değeri Repicentral distance (Deprem merkez üssüne olan uzaklık)
    min_Rhyp: Optional[float] = None         # Minimum Rhyp değeri Hypocentral distance (Deprem hiposantrına olan uzaklık)
    max_Rhyp: Optional[float] = None         # Maksimum Rhyp değeri Hypocentral distance (Deprem hiposantrına olan uzaklık)
    min_Rjb: Optional[float] = None          # Minimum Rjb değeri Joyner-Boore distance (Yüzeye izdüşüm uzaklığı)
    max_Rjb: Optional[float] = None          # Maksimum Rjb değeri Joyner-Boore distance (Yüzeye izdüşüm uzaklığı)
    min_Rrup: Optional[float] = None         # Minimum Rrup değeri Rupture distance (Kırılma uzaklığı)
    max_Rrup: Optional[float] = None         # Maksimum Rrup değeri Rupture distance (Kırılma uzaklığı)
    min_vs30: Optional[float] = None         # Minimum Vs30 değeri
    max_vs30: Optional[float] = None         # Maksimum Vs30 değeri
    mechanisms: Optional[List[str]] = Field(default_factory=list) # Fay mekanizması (ör: StrikeSlip, Normal, Reverse, Oblique)
    region: Optional[str] = None       # Bölge adı (örn: "Marmara", "Ege", "Doğu Anadolu" gibi AFAD'ın bölge tanımlarından biri)
    bbox: Optional[Tuple[float, float, float, float]] = None # BBox formatı: (min_lat, max_lat, min_lon, max_lon)

    # Kullanıcı boş bırakırsa ve hem min hem max verdiyse sistem (min+max)/2
    # formülünü kullanır. Yalnizca min veya yalnizca max verildiyse kriter
    # sadece filtredir; hedef olusmaz ve puanlamaya katilmaz.
    target_magnitude: Optional[float] = None
    target_rjb: Optional[float] = None
    target_rrup: Optional[float] = None
    target_repi: Optional[float] = None
    target_vs30: Optional[float] = None
    target_pga: Optional[float] = None
    target_pgv: Optional[float] = None
    target_pgd: Optional[float] = None
    target_t90: Optional[float] = None
    target_arias: Optional[float] = None
    target_depth: Optional[float] = None

    # -- Dinamik Ağırlıklar --
    weights: ScoringWeights = Field(default_factory=ScoringWeights)

    # --- Yardımcı Metodlar ---
    def _get_range_value(self, prefix: str, key: str) -> Optional[float]:
        """Return min/max field values while preserving legacy distance casing."""
        aliases = {
            "rjb": "Rjb",
            "rrup": "Rrup",
            "repi": "Repi",
            "rhyp": "Rhyp",
        }
        value = getattr(self, f"{prefix}_{key}", None)
        if value is not None:
            return value
        alias = aliases.get(key)
        if alias is None:
            return None
        return getattr(self, f"{prefix}_{alias}", None)

    def get_effective_target(self, key: str) -> Optional[float]:
        """
        Belirli bir parametre icin hedef degeri dondurur.

        1. ``target_X`` verildiyse o deger.
        2. Hem ``min_X`` hem ``max_X`` verildiyse aralik ortasi.
        3. Aksi halde None (kriter puanlamaya/siralamaya katilmaz).

        Tek tarafli sinir (yalnizca ``min_X`` veya yalnizca ``max_X``) bir
        **filtredir**, hedef degildir. Aksi halde ornegin yalnizca
        ``min_magnitude=5.0`` verildiginde secim M5 civarina yigilir ve daha
        buyuk depremler elenir.
        """
        explicit = getattr(self, f"target_{key}", None)
        if explicit is not None:
            return explicit

        min_val = self._get_range_value("min", key)
        max_val = self._get_range_value("max", key)
        if min_val is not None and max_val is not None:
            return (min_val + max_val) / 2.0

        return None

    def has_scoring_targets(self) -> bool:
        """En az bir kriter icin hedef (veya mekanizma) varsa True."""
        if self.get_mechanism_targets():
            return True
        return any(
            self.get_effective_target(key) is not None
            for key, config in SCORING_MAP.items()
            if config.get("type") == "numeric"
        )

    def get_sigma(self, key: str) -> float:
        """Config dosyasından o parametre için belirlenen katılık (strictness) değerini kullanarak sigma hesaplar."""
        config = SCORING_MAP.get(key, {})
        strictness = config.get('sigma_strictness', 4.0)

        # Eğer kullanıcının bir aralığı varsa, aralığı baz al
        min_val = self._get_range_value("min", key)
        max_val = self._get_range_value("max", key)

        if min_val is not None and max_val is not None:
            diff = max_val - min_val
            return diff / strictness if diff > 0 else 1.0

        # Iki tarafli aralik yoksa yalnizca acik target_X'in %10'u kullanilir.
        # Tek tarafli sinir (yalnizca min/max) hedef sayilmaz.
        target = self.get_effective_target(key)
        return (target * 0.1) if target else 1.0

    def scoring_preset_docs(self) -> Dict[str, str]:
        """Built-in scoring preset descriptions for UI/CLI documentation."""
        return ScoringWeights.preset_descriptions()

    def get_mechanism_targets(self) -> List[str]:
        """Return all requested fault mechanism labels from both public fields."""
        targets: List[str] = []
        if self.mechanisms:
            targets.extend(self.mechanisms)
        if self.fault_type:
            targets.append(self.fault_type)
        return list(dict.fromkeys(targets))

    def to_afad_params(self) -> Dict[str, Any]:
        """AFAD API'sine özel parametre dönüşümü"""
        params = {
            "startDate"     : f"{self.start_date}T00:00:00.000Z" if self.start_date else None,
            "endDate"       : f"{self.end_date}T23:59:59.999Z" if self.end_date else None,

            "fromLatitude"  : self.min_latitude,
            "toLatitude"    : self.max_latitude,
            "fromLongitude" : self.min_longitude,
            "toLongitude"   : self.max_longitude,

            "fromMagnitude" : self.min_magnitude,
            "toMagnitude"   : self.max_magnitude,

            "from_depth"    : self.min_depth,  
            "to_depth"      : self.max_depth, 
            # Mesafe sinirlari (Repi/Rhyp/Rjb/Rrup) AFAD'a gonderilmez; AFAD
            # fromRrup/toRrup ile her zaman bos liste donduruyor. Bunlar yanit
            # geldikten sonra istemci tarafinda uygulanir (afad_distance_bounds).
            "fromVs30"      : self.min_vs30,
            "toVs30"        : self.max_vs30,
            "fromPGA"       : self.min_pga,
            "toPGA"         : self.max_pga,
            "fromPGV"       : self.min_pgv,
            "toPGV"         : self.max_pgv,
            "fromPgd"       : self.min_pgd,
            "toPgd"         : self.max_pgd,


            "fromT90"       : None,            
            "country"       : self.country,  
            "province"      : self.province,  
            "district"      : self.district,  
        }

        # if self.region:
        #     params["region"] = self.region

        mechanism_targets = self.get_mechanism_targets()
        if mechanism_targets:
            # AFAD fay mekanizması parametrelerine dönüşüm
            mechanism_map = {
                "StrikeSlip": "SS",
                "Reverse": "R",
                "Normal": "N",
                "Oblique": "T"
            }
            mechParams = [mechanism_map.get(m, m) for m in mechanism_targets]
            params["faultType"] = mechParams[0]
        params = {k: v for k, v in params.items() if v is not None}
        return params

    def afad_distance_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
        """AFAD yanitina istemci tarafinda uygulanacak mesafe sinirlari.

        Anahtar AFAD yanitindaki ham kolon adidir (``repi``, ``rhyp``, ``rjb``,
        ``rrup``); deger (min, max) ciftidir. Yalnizca en az bir siniri
        verilmis mesafeler dondurulur.
        """
        bounds = {
            "repi": (self.min_Repi, self.max_Repi),
            "rhyp": (self.min_Rhyp, self.max_Rhyp),
            "rjb": (self.min_Rjb, self.max_Rjb),
            "rrup": (self.min_Rrup, self.max_Rrup),
        }
        return {
            column: limits for column, limits in bounds.items()
            if limits[0] is not None or limits[1] is not None
        }

    def to_peer_params(self) -> Dict[str, Any]:
        """PEER veritabanına özel parametre dönüşümü"""
        params = {
            "year_start": int(self.start_date[:4]),
            "year_end": int(self.end_date[:4]),
            "min_magnitude": self.min_magnitude,
            "max_magnitude": self.max_magnitude,
            "min_vs30": self.min_vs30,
            "max_vs30": self.max_vs30,
            'min_Rjb': self.min_Rjb,
            'max_Rjb': self.max_Rjb,
            'min_Rrup':self.min_Rrup ,
            'max_Rrup':self.max_Rrup,
            'min_depth': self.min_depth,
            'max_depth': self.max_depth,
            'min_pga': self.min_pga,
            'max_pga': self.max_pga,
            'min_pgv': self.min_pgv,
            'max_pgv': self.max_pgv,
            'min_pgd': self.min_pgd,
            'max_pgd': self.max_pgd,
            'mechanisms': self.get_mechanism_targets()
        }

        # Konum filtresi: bbox (min_lat, max_lat, min_lon, max_lon) tek ve açık
        # öncelikle ayrı min/max alanlarının önüne geçer.
        if self.bbox:
            (
                params["min_latitude"],
                params["max_latitude"],
                params["min_longitude"],
                params["max_longitude"],
            ) = self.bbox
        else:
            params["min_latitude"] = self.min_latitude
            params["max_latitude"] = self.max_latitude
            params["min_longitude"] = self.min_longitude
            params["max_longitude"] = self.max_longitude
        params["circleLatitude"] = self.circleLatitude
        params["circleLongitude"] = self.circleLongitude
        params["circleRadius"] = self.circleRadius

        mechanism_targets = self.get_mechanism_targets()
        if mechanism_targets:
            params["mechanisms"] = [
                get_mechanism_numeric(m)
                for m in mechanism_targets
                if m in REVERSE_MECHANISM_MAP
            ]

        return params

    def to_fdsn_params(self) -> Dict[str, Any]:
        """FDSN standardına özel parametre dönüşümü
            starttime: Any | None = None,
            endtime: Any | None = None,
            minlatitude: Any | None = None,
            maxlatitude: Any | None = None,
            minlongitude: Any | None = None,
            maxlongitude: Any | None = None,
            latitude: Any | None = None,
            longitude: Any | None = None,
            minradius: Any | None = None,
            maxradius: Any | None = None,
            mindepth: Any | None = None,
            maxdepth: Any | None = None,
            minmagnitude: Any | None = None,
            maxmagnitude: Any | None = None,
            magnitudetype: Any | None = None,
            eventtype: Any | None = None,
            includeallorigins: Any | None = None,
            includeallmagnitudes: Any | None = None,
            includearrivals: Any | None = None,
            eventid: Any | None = None,
            limit: Any | None = None,
            offset: Any | None = None,
            orderby: Any | None = None,
            catalog: Any | None = None,
            contributor: Any | None = None,
            updatedafter: Any | None = None,
            filename: Any | None = None,
            **kwargs
        """
        def fdsn_time(value: str, end_of_day: bool = False) -> str:
            """Keep timestamps intact and expand date-only values to UTC days."""
            if "T" in value:
                return value
            suffix = "T23:59:59.999Z" if end_of_day else "T00:00:00.000Z"
            return f"{value}{suffix}"

        params = {
            "starttime": fdsn_time(self.start_date),
            "endtime": fdsn_time(self.end_date, end_of_day=True),
            "minmagnitude": self.min_magnitude,
            "maxmagnitude": self.max_magnitude,
            "mindepth": self.min_depth,
            "maxdepth": self.max_depth,
        }

        if self.bbox:
            (
                params["minlatitude"],
                params["maxlatitude"],
                params["minlongitude"],
                params["maxlongitude"],
            ) = self.bbox
        else:
            # Ortak koordinat alanları AFAD ile aynı arama bölgesini FDSN'e de
            # taşır. bbox verilmişse tek ve açık bir öncelik kuralı uygulanır.
            params.update(
                {
                    "minlatitude": self.min_latitude,
                    "maxlatitude": self.max_latitude,
                    "minlongitude": self.min_longitude,
                    "maxlongitude": self.max_longitude,
                }
            )

        return {key: value for key, value in params.items() if value is not None}

    def to_esm_params(self) -> Dict[str, Any]:
        """ESM flatfile servisinin sunucu tarafinda uyguladigi filtreler."""
        params: Dict[str, Any] = {
            "starttime": self.start_date,
            "endtime": self.end_date if "T" in self.end_date else f"{self.end_date}T23:59:59",
            "min-magnitude": self.min_magnitude,
            "max-magnitude": self.max_magnitude,
            "min-event-depth": self.min_depth,
            "max-event-depth": self.max_depth,
            "min-vs30": self.min_vs30,
            "max-vs30": self.max_vs30,
            "min-joynerboore-distance": self.min_Rjb,
            "max-joynerboore-distance": self.max_Rjb,
            "network": self.network,
            "station": self.station_code,
        }
        box = self.bbox or (self.min_latitude, self.max_latitude, self.min_longitude, self.max_longitude)
        for key, value in zip(
            ("min-event-latitude", "max-event-latitude", "min-event-longitude", "max-event-longitude"), box
        ):
            params[key] = value
        mechanisms = self.get_mechanism_targets()
        codes = {"Normal": "NF", "Reverse": "TF", "StrikeSlip": "SS"}
        # Yalniz hepsi ESM koduna denk geliyorsa sunucuya gonderilir; yoksa istemcide suzulur.
        if mechanisms and all(m in codes for m in mechanisms):
            params["style-of-faulting"] = ",".join(codes[m] for m in mechanisms)
        if self.station_code or self.network:
            params["contains-string"] = "False"
        return {key: value for key, value in params.items() if value is not None}

    def esm_client_filters(self) -> Dict[str, Any]:
        """ESM yanitina istemci tarafinda uygulanacak sinirlar (bkz. ESMProvider)."""
        filters: Dict[str, Any] = {
            "min_magnitude": self.min_magnitude, "max_magnitude": self.max_magnitude,
            "min_depth": self.min_depth, "max_depth": self.max_depth,
            "min_vs30": self.min_vs30, "max_vs30": self.max_vs30,
            "min_Rjb": self.min_Rjb, "max_Rjb": self.max_Rjb,
            "min_Rrup": self.min_Rrup, "max_Rrup": self.max_Rrup,
            "min_Repi": self.min_Repi, "max_Repi": self.max_Repi,
            "min_Rhyp": self.min_Rhyp, "max_Rhyp": self.max_Rhyp,
            "min_pga": self.min_pga, "max_pga": self.max_pga,
            "min_pgv": self.min_pgv, "max_pgv": self.max_pgv,
            "min_pgd": self.min_pgd, "max_pgd": self.max_pgd,
            "mechanisms": self.get_mechanism_targets() or None,
        }
        if self.circleLatitude is not None and self.circleRadius is not None:
            filters["circle"] = [self.circleLatitude, self.circleLongitude, self.circleRadius]
        return {key: value for key, value in filters.items() if value is not None}

    @model_validator(mode='after')
    def check_magnitudes(self):
        if self.min_magnitude is not None and self.max_magnitude is not None:
            if self.min_magnitude > self.max_magnitude:
                raise ValueError("Minimum büyüklük maksimum büyüklükten büyük olamaz.")
            if self.min_magnitude < 0 or self.max_magnitude > 10:
                raise ValueError("Büyüklük değerleri 0-10 aralığında olmalıdır.")
        return self

    @model_validator(mode='after')
    def check_dates(self):
        try:
            start = datetime.fromisoformat(self.start_date.replace('Z', '+00:00'))
            end = datetime.fromisoformat(self.end_date.replace('Z', '+00:00'))
            if start > end:
                raise ValueError("Başlangıç tarihi bitiş tarihinden sonra olamaz.")
        except ValueError as e:
            raise ValueError(f"Geçersiz tarih formatı: {e}")
        return self

    @model_validator(mode='after')
    def check_bbox(self):
        if self.bbox:
            min_lat, max_lat, min_lon, max_lon = self.bbox
            if not (-90 <= min_lat <= 90) or not (-90 <= max_lat <= 90):
                raise ValueError("Enlem değerleri -90 ile 90 arasında olmalıdır.")
            if not (-180 <= min_lon <= 180) or not (-180 <= max_lon <= 180):
                raise ValueError("Boylam değerleri -180 ile 180 arasında olmalıdır.")
            if min_lat > max_lat or min_lon > max_lon:
                raise ValueError("Bbox koordinatları doğru sırada olmalıdır (min_lat, max_lat, min_lon, max_lon).")
        return self

    @model_validator(mode='after')
    def check_vs30(self):
        if self.min_vs30 is not None and self.max_vs30 is not None:
            if self.min_vs30 > self.max_vs30:
                raise ValueError("Minimum VS30 maksimum VS30'dan büyük olamaz.")
            if self.min_vs30 < 0 or self.max_vs30 > 3000:
                raise ValueError("VS30 değerleri 0-3000 m/s aralığında olmalıdır.")
        return self

    @model_validator(mode='after')
    def check_mechanisms(self):
        valid_mechanisms = set(MECHANISM_MAP.values())
        for mechanism in self.get_mechanism_targets():
            if mechanism not in valid_mechanisms:
                raise ValueError(f"Geçersiz mekanizma: {mechanism}. Geçerli mekanizmalar: {list(valid_mechanisms)}")
        return self

    @model_validator(mode='after')
    def check_distances(self):
        distance_fields = [
            ('min_Repi', 'max_Repi'), ('min_Rhyp', 'max_Rhyp'),
            ('min_Rjb', 'max_Rjb'), ('min_Rrup', 'max_Rrup')
        ]

        for min_field, max_field in distance_fields:
            min_val = getattr(self, min_field, None)
            max_val = getattr(self, max_field, None)

            if min_val is not None and max_val is not None and min_val > max_val:
                raise ValueError(f"{min_field} {max_field}'den büyük olamaz.")
            if min_val is not None and min_val < 0:
                raise ValueError(f"{min_field} negatif olamaz.")
        return self

    @model_validator(mode='after')
    def check_depths(self):
        if self.min_depth is not None and self.max_depth is not None:
            if self.min_depth > self.max_depth:
                raise ValueError("Minimum derinlik maksimum derinlikten büyük olamaz.")
            if self.min_depth < 0 or self.max_depth > 700:
                raise ValueError("Derinlik değerleri 0-700 km aralığında olmalıdır.")
        return self

    @model_validator(mode='after')
    def check_pga_pgv_pgd(self):
        if self.min_pga is not None and self.max_pga is not None:
            if self.min_pga > self.max_pga:
                raise ValueError("Minimum PGA maksimum PGA'dan büyük olamaz.")
            if self.min_pga < 0 or self.max_pga > 10000:
                raise ValueError("PGA değerleri 0-10000 cm/s² aralığında olmalıdır.")

        if self.min_pgv is not None and self.max_pgv is not None:
            if self.min_pgv > self.max_pgv:
                raise ValueError("Minimum PGV maksimum PGV'den büyük olamaz.")
            if self.min_pgv < 0 or self.max_pgv > 1000:
                raise ValueError("PGV değerleri 0-1000 cm/s aralığında olmalıdır.")

        if self.min_pgd is not None and self.max_pgd is not None:
            if self.min_pgd > self.max_pgd:
                raise ValueError("Minimum PGD maksimum PGD'den büyük olamaz.")
            if self.min_pgd < 0 or self.max_pgd > 1000:
                raise ValueError("PGD değerleri 0-1000 cm aralığında olmalıdır.")
        return self

    @model_validator(mode='after')
    def check_circle_search(self):
        if (self.circleLatitude is not None or self.circleLongitude is not None or self.circleRadius is not None):
            if self.circleLatitude is None or self.circleLongitude is None or self.circleRadius is None:
                raise ValueError("Dairesel arama için circleLatitude, circleLongitude ve circleRadius birlikte sağlanmalıdır.")
            if not (-90 <= self.circleLatitude <= 90):
                raise ValueError("circleLatitude -90 ile 90 arasında olmalıdır.")
            if not (-180 <= self.circleLongitude <= 180):
                raise ValueError("circleLongitude -180 ile 180 arasında olmalıdır.")
            if self.circleRadius < 0:
                raise ValueError("circleRadius negatif olamaz.")
        return self

get_effective_target(key)

Belirli bir parametre icin hedef degeri dondurur.

  1. target_X verildiyse o deger.
  2. Hem min_X hem max_X verildiyse aralik ortasi.
  3. Aksi halde None (kriter puanlamaya/siralamaya katilmaz).

Tek tarafli sinir (yalnizca min_X veya yalnizca max_X) bir filtredir, hedef degildir. Aksi halde ornegin yalnizca min_magnitude=5.0 verildiginde secim M5 civarina yigilir ve daha buyuk depremler elenir.

Source code in src/selection_service/processing/criteria.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def get_effective_target(self, key: str) -> Optional[float]:
    """
    Belirli bir parametre icin hedef degeri dondurur.

    1. ``target_X`` verildiyse o deger.
    2. Hem ``min_X`` hem ``max_X`` verildiyse aralik ortasi.
    3. Aksi halde None (kriter puanlamaya/siralamaya katilmaz).

    Tek tarafli sinir (yalnizca ``min_X`` veya yalnizca ``max_X``) bir
    **filtredir**, hedef degildir. Aksi halde ornegin yalnizca
    ``min_magnitude=5.0`` verildiginde secim M5 civarina yigilir ve daha
    buyuk depremler elenir.
    """
    explicit = getattr(self, f"target_{key}", None)
    if explicit is not None:
        return explicit

    min_val = self._get_range_value("min", key)
    max_val = self._get_range_value("max", key)
    if min_val is not None and max_val is not None:
        return (min_val + max_val) / 2.0

    return None

has_scoring_targets()

En az bir kriter icin hedef (veya mekanizma) varsa True.

Source code in src/selection_service/processing/criteria.py
213
214
215
216
217
218
219
220
221
def has_scoring_targets(self) -> bool:
    """En az bir kriter icin hedef (veya mekanizma) varsa True."""
    if self.get_mechanism_targets():
        return True
    return any(
        self.get_effective_target(key) is not None
        for key, config in SCORING_MAP.items()
        if config.get("type") == "numeric"
    )

get_sigma(key)

Config dosyasından o parametre için belirlenen katılık (strictness) değerini kullanarak sigma hesaplar.

Source code in src/selection_service/processing/criteria.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def get_sigma(self, key: str) -> float:
    """Config dosyasından o parametre için belirlenen katılık (strictness) değerini kullanarak sigma hesaplar."""
    config = SCORING_MAP.get(key, {})
    strictness = config.get('sigma_strictness', 4.0)

    # Eğer kullanıcının bir aralığı varsa, aralığı baz al
    min_val = self._get_range_value("min", key)
    max_val = self._get_range_value("max", key)

    if min_val is not None and max_val is not None:
        diff = max_val - min_val
        return diff / strictness if diff > 0 else 1.0

    # Iki tarafli aralik yoksa yalnizca acik target_X'in %10'u kullanilir.
    # Tek tarafli sinir (yalnizca min/max) hedef sayilmaz.
    target = self.get_effective_target(key)
    return (target * 0.1) if target else 1.0

scoring_preset_docs()

Built-in scoring preset descriptions for UI/CLI documentation.

Source code in src/selection_service/processing/criteria.py
241
242
243
def scoring_preset_docs(self) -> Dict[str, str]:
    """Built-in scoring preset descriptions for UI/CLI documentation."""
    return ScoringWeights.preset_descriptions()

get_mechanism_targets()

Return all requested fault mechanism labels from both public fields.

Source code in src/selection_service/processing/criteria.py
245
246
247
248
249
250
251
252
def get_mechanism_targets(self) -> List[str]:
    """Return all requested fault mechanism labels from both public fields."""
    targets: List[str] = []
    if self.mechanisms:
        targets.extend(self.mechanisms)
    if self.fault_type:
        targets.append(self.fault_type)
    return list(dict.fromkeys(targets))

to_afad_params()

AFAD API'sine özel parametre dönüşümü

Source code in src/selection_service/processing/criteria.py
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
def to_afad_params(self) -> Dict[str, Any]:
    """AFAD API'sine özel parametre dönüşümü"""
    params = {
        "startDate"     : f"{self.start_date}T00:00:00.000Z" if self.start_date else None,
        "endDate"       : f"{self.end_date}T23:59:59.999Z" if self.end_date else None,

        "fromLatitude"  : self.min_latitude,
        "toLatitude"    : self.max_latitude,
        "fromLongitude" : self.min_longitude,
        "toLongitude"   : self.max_longitude,

        "fromMagnitude" : self.min_magnitude,
        "toMagnitude"   : self.max_magnitude,

        "from_depth"    : self.min_depth,  
        "to_depth"      : self.max_depth, 
        # Mesafe sinirlari (Repi/Rhyp/Rjb/Rrup) AFAD'a gonderilmez; AFAD
        # fromRrup/toRrup ile her zaman bos liste donduruyor. Bunlar yanit
        # geldikten sonra istemci tarafinda uygulanir (afad_distance_bounds).
        "fromVs30"      : self.min_vs30,
        "toVs30"        : self.max_vs30,
        "fromPGA"       : self.min_pga,
        "toPGA"         : self.max_pga,
        "fromPGV"       : self.min_pgv,
        "toPGV"         : self.max_pgv,
        "fromPgd"       : self.min_pgd,
        "toPgd"         : self.max_pgd,


        "fromT90"       : None,            
        "country"       : self.country,  
        "province"      : self.province,  
        "district"      : self.district,  
    }

    # if self.region:
    #     params["region"] = self.region

    mechanism_targets = self.get_mechanism_targets()
    if mechanism_targets:
        # AFAD fay mekanizması parametrelerine dönüşüm
        mechanism_map = {
            "StrikeSlip": "SS",
            "Reverse": "R",
            "Normal": "N",
            "Oblique": "T"
        }
        mechParams = [mechanism_map.get(m, m) for m in mechanism_targets]
        params["faultType"] = mechParams[0]
    params = {k: v for k, v in params.items() if v is not None}
    return params

afad_distance_bounds()

AFAD yanitina istemci tarafinda uygulanacak mesafe sinirlari.

Anahtar AFAD yanitindaki ham kolon adidir (repi, rhyp, rjb, rrup); deger (min, max) ciftidir. Yalnizca en az bir siniri verilmis mesafeler dondurulur.

Source code in src/selection_service/processing/criteria.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def afad_distance_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
    """AFAD yanitina istemci tarafinda uygulanacak mesafe sinirlari.

    Anahtar AFAD yanitindaki ham kolon adidir (``repi``, ``rhyp``, ``rjb``,
    ``rrup``); deger (min, max) ciftidir. Yalnizca en az bir siniri
    verilmis mesafeler dondurulur.
    """
    bounds = {
        "repi": (self.min_Repi, self.max_Repi),
        "rhyp": (self.min_Rhyp, self.max_Rhyp),
        "rjb": (self.min_Rjb, self.max_Rjb),
        "rrup": (self.min_Rrup, self.max_Rrup),
    }
    return {
        column: limits for column, limits in bounds.items()
        if limits[0] is not None or limits[1] is not None
    }

to_peer_params()

PEER veritabanına özel parametre dönüşümü

Source code in src/selection_service/processing/criteria.py
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
def to_peer_params(self) -> Dict[str, Any]:
    """PEER veritabanına özel parametre dönüşümü"""
    params = {
        "year_start": int(self.start_date[:4]),
        "year_end": int(self.end_date[:4]),
        "min_magnitude": self.min_magnitude,
        "max_magnitude": self.max_magnitude,
        "min_vs30": self.min_vs30,
        "max_vs30": self.max_vs30,
        'min_Rjb': self.min_Rjb,
        'max_Rjb': self.max_Rjb,
        'min_Rrup':self.min_Rrup ,
        'max_Rrup':self.max_Rrup,
        'min_depth': self.min_depth,
        'max_depth': self.max_depth,
        'min_pga': self.min_pga,
        'max_pga': self.max_pga,
        'min_pgv': self.min_pgv,
        'max_pgv': self.max_pgv,
        'min_pgd': self.min_pgd,
        'max_pgd': self.max_pgd,
        'mechanisms': self.get_mechanism_targets()
    }

    # Konum filtresi: bbox (min_lat, max_lat, min_lon, max_lon) tek ve açık
    # öncelikle ayrı min/max alanlarının önüne geçer.
    if self.bbox:
        (
            params["min_latitude"],
            params["max_latitude"],
            params["min_longitude"],
            params["max_longitude"],
        ) = self.bbox
    else:
        params["min_latitude"] = self.min_latitude
        params["max_latitude"] = self.max_latitude
        params["min_longitude"] = self.min_longitude
        params["max_longitude"] = self.max_longitude
    params["circleLatitude"] = self.circleLatitude
    params["circleLongitude"] = self.circleLongitude
    params["circleRadius"] = self.circleRadius

    mechanism_targets = self.get_mechanism_targets()
    if mechanism_targets:
        params["mechanisms"] = [
            get_mechanism_numeric(m)
            for m in mechanism_targets
            if m in REVERSE_MECHANISM_MAP
        ]

    return params

to_fdsn_params()

FDSN standardına özel parametre dönüşümü starttime: Any | None = None, endtime: Any | None = None, minlatitude: Any | None = None, maxlatitude: Any | None = None, minlongitude: Any | None = None, maxlongitude: Any | None = None, latitude: Any | None = None, longitude: Any | None = None, minradius: Any | None = None, maxradius: Any | None = None, mindepth: Any | None = None, maxdepth: Any | None = None, minmagnitude: Any | None = None, maxmagnitude: Any | None = None, magnitudetype: Any | None = None, eventtype: Any | None = None, includeallorigins: Any | None = None, includeallmagnitudes: Any | None = None, includearrivals: Any | None = None, eventid: Any | None = None, limit: Any | None = None, offset: Any | None = None, orderby: Any | None = None, catalog: Any | None = None, contributor: Any | None = None, updatedafter: Any | None = None, filename: Any | None = None, **kwargs

Source code in src/selection_service/processing/criteria.py
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
def to_fdsn_params(self) -> Dict[str, Any]:
    """FDSN standardına özel parametre dönüşümü
        starttime: Any | None = None,
        endtime: Any | None = None,
        minlatitude: Any | None = None,
        maxlatitude: Any | None = None,
        minlongitude: Any | None = None,
        maxlongitude: Any | None = None,
        latitude: Any | None = None,
        longitude: Any | None = None,
        minradius: Any | None = None,
        maxradius: Any | None = None,
        mindepth: Any | None = None,
        maxdepth: Any | None = None,
        minmagnitude: Any | None = None,
        maxmagnitude: Any | None = None,
        magnitudetype: Any | None = None,
        eventtype: Any | None = None,
        includeallorigins: Any | None = None,
        includeallmagnitudes: Any | None = None,
        includearrivals: Any | None = None,
        eventid: Any | None = None,
        limit: Any | None = None,
        offset: Any | None = None,
        orderby: Any | None = None,
        catalog: Any | None = None,
        contributor: Any | None = None,
        updatedafter: Any | None = None,
        filename: Any | None = None,
        **kwargs
    """
    def fdsn_time(value: str, end_of_day: bool = False) -> str:
        """Keep timestamps intact and expand date-only values to UTC days."""
        if "T" in value:
            return value
        suffix = "T23:59:59.999Z" if end_of_day else "T00:00:00.000Z"
        return f"{value}{suffix}"

    params = {
        "starttime": fdsn_time(self.start_date),
        "endtime": fdsn_time(self.end_date, end_of_day=True),
        "minmagnitude": self.min_magnitude,
        "maxmagnitude": self.max_magnitude,
        "mindepth": self.min_depth,
        "maxdepth": self.max_depth,
    }

    if self.bbox:
        (
            params["minlatitude"],
            params["maxlatitude"],
            params["minlongitude"],
            params["maxlongitude"],
        ) = self.bbox
    else:
        # Ortak koordinat alanları AFAD ile aynı arama bölgesini FDSN'e de
        # taşır. bbox verilmişse tek ve açık bir öncelik kuralı uygulanır.
        params.update(
            {
                "minlatitude": self.min_latitude,
                "maxlatitude": self.max_latitude,
                "minlongitude": self.min_longitude,
                "maxlongitude": self.max_longitude,
            }
        )

    return {key: value for key, value in params.items() if value is not None}

to_esm_params()

ESM flatfile servisinin sunucu tarafinda uyguladigi filtreler.

Source code in src/selection_service/processing/criteria.py
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
def to_esm_params(self) -> Dict[str, Any]:
    """ESM flatfile servisinin sunucu tarafinda uyguladigi filtreler."""
    params: Dict[str, Any] = {
        "starttime": self.start_date,
        "endtime": self.end_date if "T" in self.end_date else f"{self.end_date}T23:59:59",
        "min-magnitude": self.min_magnitude,
        "max-magnitude": self.max_magnitude,
        "min-event-depth": self.min_depth,
        "max-event-depth": self.max_depth,
        "min-vs30": self.min_vs30,
        "max-vs30": self.max_vs30,
        "min-joynerboore-distance": self.min_Rjb,
        "max-joynerboore-distance": self.max_Rjb,
        "network": self.network,
        "station": self.station_code,
    }
    box = self.bbox or (self.min_latitude, self.max_latitude, self.min_longitude, self.max_longitude)
    for key, value in zip(
        ("min-event-latitude", "max-event-latitude", "min-event-longitude", "max-event-longitude"), box
    ):
        params[key] = value
    mechanisms = self.get_mechanism_targets()
    codes = {"Normal": "NF", "Reverse": "TF", "StrikeSlip": "SS"}
    # Yalniz hepsi ESM koduna denk geliyorsa sunucuya gonderilir; yoksa istemcide suzulur.
    if mechanisms and all(m in codes for m in mechanisms):
        params["style-of-faulting"] = ",".join(codes[m] for m in mechanisms)
    if self.station_code or self.network:
        params["contains-string"] = "False"
    return {key: value for key, value in params.items() if value is not None}

esm_client_filters()

ESM yanitina istemci tarafinda uygulanacak sinirlar (bkz. ESMProvider).

Source code in src/selection_service/processing/criteria.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
def esm_client_filters(self) -> Dict[str, Any]:
    """ESM yanitina istemci tarafinda uygulanacak sinirlar (bkz. ESMProvider)."""
    filters: Dict[str, Any] = {
        "min_magnitude": self.min_magnitude, "max_magnitude": self.max_magnitude,
        "min_depth": self.min_depth, "max_depth": self.max_depth,
        "min_vs30": self.min_vs30, "max_vs30": self.max_vs30,
        "min_Rjb": self.min_Rjb, "max_Rjb": self.max_Rjb,
        "min_Rrup": self.min_Rrup, "max_Rrup": self.max_Rrup,
        "min_Repi": self.min_Repi, "max_Repi": self.max_Repi,
        "min_Rhyp": self.min_Rhyp, "max_Rhyp": self.max_Rhyp,
        "min_pga": self.min_pga, "max_pga": self.max_pga,
        "min_pgv": self.min_pgv, "max_pgv": self.max_pgv,
        "min_pgd": self.min_pgd, "max_pgd": self.max_pgd,
        "mechanisms": self.get_mechanism_targets() or None,
    }
    if self.circleLatitude is not None and self.circleRadius is not None:
        filters["circle"] = [self.circleLatitude, self.circleLongitude, self.circleRadius]
    return {key: value for key, value in filters.items() if value is not None}

selection_service.processing.strategies.ISelectionStrategy

Bases: Protocol

Seçim stratejisi interface'i

Source code in src/selection_service/processing/strategies/base.py
13
14
15
16
17
18
19
20
21
22
class ISelectionStrategy(Protocol):
    """Seçim stratejisi interface'i"""

    def select_and_score(self, df: pd.DataFrame, criteria: SearchCriteria) -> Tuple[pd.DataFrame, pd.DataFrame]:
        """Kayıtları seç ve puanla"""
        ...

    def get_name(self) -> str:
        """Strateji adı"""
        ...

select_and_score(df, criteria)

Kayıtları seç ve puanla

Source code in src/selection_service/processing/strategies/base.py
16
17
18
def select_and_score(self, df: pd.DataFrame, criteria: SearchCriteria) -> Tuple[pd.DataFrame, pd.DataFrame]:
    """Kayıtları seç ve puanla"""
    ...

get_name()

Strateji adı

Source code in src/selection_service/processing/strategies/base.py
20
21
22
def get_name(self) -> str:
    """Strateji adı"""
    ...

selection_service.processing.strategies.BaseSelectionStrategy

Bases: ISelectionStrategy, ABC

Temel seçim stratejisi

Source code in src/selection_service/processing/strategies/base.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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
class BaseSelectionStrategy(ISelectionStrategy, ABC):
    """Temel seçim stratejisi"""

    def __init__(self, config: SelectionConfig):
        self.config = config

    @staticmethod
    def _event_key(record: pd.Series) -> Any:
        """Olay başına kayıt sınırı için anahtar.

        Birden fazla provider'dan gelen veride ``EVENT_GROUP`` aynı depremin
        farklı adlarını tek olay sayar; yoksa ``EVENT`` kullanılır.
        """
        group = record.get("EVENT_GROUP")
        if group is not None and not pd.isna(group) and str(group) != "":
            return group
        return record.get("EVENT", "")

    def _gaussian_score(self, value: float, target: float, sigma: float) -> float:
        """Çan Eğrisi (Gaussian) Puanlama Fonksiyonu. Hedef değere tam isabet = 1.0 puan. Uzaklaştıkça puan yumuşak bir şekilde düşer.
            Gaussian Formülü: e^(-(x-u)^2 / (2*sigma^2))
        Args:
            value (float): _description_
            target (float): _description_
            sigma (float): _description_

        Returns:
            float: _description_
        """
        if value is None or target is None or pd.isna(value):
            return 0.0
        # Çan eğrisi formülü
        diff = value - target
        return math.exp(- (diff * diff) / (2 * sigma * sigma))

    def _categorical_score(self, record_val: str, target_list: list) -> float:
        """Metinsel eşleşme puanı (Mekanizma vb için)"""
        if not record_val or not target_list:
            return 0.0

        record_val_str = str(record_val)
        # Tam eşleşme
        if any(t == record_val_str for t in target_list):
            return 1.0
        # Kısmi eşleşme (Örn: "Reverse" arıyoruz, kayıt "Reverse-Oblique")
        if any(t in record_val_str for t in target_list):
            return 0.7
        return 0.0

    def _calculate_total_score(self, record: pd.Series, criteria: SearchCriteria) -> float:
        """
        DİNAMİK PUANLAMA MOTORU
        Config'deki tüm parametreleri tarar, kullanıcı ne girdiyse ona göre puanlar.
        """
        score, _ = self._calculate_score_breakdown(record, criteria)
        return score

    def _calculate_score_breakdown(
        self, record: pd.Series, criteria: SearchCriteria
    ) -> Tuple[float, List[Dict[str, Any]]]:
        """Return total score and criterion-level contribution details."""
        total_weighted_score = 0.0
        total_active_weight = 0.0
        breakdown: List[Dict[str, Any]] = []

        # Config'deki tüm parametreler üzerinde dönüyoruz (Magnitude, Rjb, Rrup, Vs30...)
        for key, config in SCORING_MAP.items():

            # 1. Bu parametre için bir hedef (Target) var mı?
            # Kullanıcı target girmediyse veya min-max aralığı vermediyse bu parametreyi ELİMİNE ET.
            if key == 'mechanism':
                # Mekanizma özel durumu: liste boşsa geç
                mechanism_targets = criteria.get_mechanism_targets()
                if not mechanism_targets:
                    continue
                target_val = mechanism_targets
            else:
                target_val = criteria.get_effective_target(key)
                if target_val is None:
                    continue

            # 2. DataFrame'de bu veri var mı?
            col_name = config['column']
            if col_name not in record or pd.isna(record[col_name]):
                # Kullanıcı hedef istemiş ama veri setinde (örneğin PEER'de) bu kolon yoksa puanlamaya katma
                breakdown.append({
                    "criterion": key,
                    "column": col_name,
                    "status": "missing",
                    "target": target_val,
                    "value": None,
                    "weight": criteria.weights.get_weight(key),
                    "raw_score": 0.0,
                    "weighted_score": 0.0,
                })
                continue

            # 3. Ağırlığı al
            weight = criteria.weights.get_weight(key)
            if weight <= 0:
                breakdown.append({
                    "criterion": key,
                    "column": col_name,
                    "status": "inactive_weight",
                    "target": target_val,
                    "value": record[col_name],
                    "weight": weight,
                    "raw_score": 0.0,
                    "weighted_score": 0.0,
                })
                continue

            # 4. Puanı Hesapla
            score = 0.0
            sigma = None
            if config['type'] == 'numeric':
                sigma = criteria.get_sigma(key)
                score = self._gaussian_score(record[col_name], target_val, sigma)

            elif config['type'] == 'categorical':
                score = self._categorical_score(record[col_name], target_val)

            # 5. Toplama Ekle
            total_weighted_score += score * weight
            total_active_weight += weight
            breakdown.append({
                "criterion": key,
                "column": col_name,
                "status": "active",
                "target": target_val,
                "value": record[col_name],
                "weight": weight,
                "sigma": sigma,
                "raw_score": score,
                "weighted_score": score * weight,
            })

        # 6. Normalizasyon (0-100 arası)
        # Eğer hiçbir kriter girilmediyse 0 döndür
        if total_active_weight == 0:
            return 0.0, breakdown

        return (total_weighted_score / total_active_weight) * 100.0, breakdown

    def select_and_score(self, df: pd.DataFrame, criteria: SearchCriteria) -> Tuple[pd.DataFrame, pd.DataFrame]:
        """ Kayıtları puanla ve seç. 

        Args:
            df (pd.DataFrame): Puanlanacak veri seti
            criteria (SearchCriteria): Kullanıcının girdiği arama kriterleri ve ağırlıklar

        Returns:
            Tuple[pd.DataFrame, pd.DataFrame]: Seçilen kayıtlar ve tüm kayıtların puanlı hali
        """
        if df.empty:
            return pd.DataFrame(), pd.DataFrame()

        scored_df = df.copy()

        # Vektörize işlem yerine apply kullanıyoruz (karmaşık mantık için daha güvenli)
        # Performans gerekirse numpy ile vektörize edilebilir.
        score_results = scored_df.apply(
            lambda row: self._calculate_score_breakdown(row, criteria), axis=1
        )
        scored_df['SCORE'] = score_results.apply(lambda item: item[0])
        scored_df['SCORE_BREAKDOWN'] = score_results.apply(lambda item: item[1])

        # Hicbir kriter icin hedef yoksa (or. yalnizca min_magnitude) skor
        # anlamsizdir (hepsi 0): min_score uygulanmaz, varsayilan siralama
        # (buyuk MAGNITUDE, sonra kucuk mesafe) kullanilir.
        selected_df, scored_df = self._apply_selection_rules_with_reasons(
            scored_df, has_targets=criteria.has_scoring_targets()
        )
        return selected_df, scored_df

    def _apply_selection_rules(self, df_scored: pd.DataFrame) -> pd.DataFrame:
        """Seçim kurallarını uygula"""
        selected, _ = self._apply_selection_rules_with_reasons(df_scored)
        return selected

    def _apply_selection_rules_with_reasons(
        self, df_scored: pd.DataFrame, has_targets: bool = True
    ) -> Tuple[pd.DataFrame, pd.DataFrame]:
        """Apply TBDY selection limits and annotate every record with a reason.

        ``has_targets=False`` ise ``min_score`` esigi uygulanmaz ve adaylar
        varsayilan duzende (buyuk MAGNITUDE, sonra kucuk mesafe) siralanir.
        """
        df_scored = df_scored.copy()
        df_scored["SELECTION_STATUS"] = "not_evaluated"
        df_scored["SELECTION_REASON"] = ""

        if not has_targets:
            return self._select_in_order(order_candidates(df_scored), df_scored)

        filtered_df = df_scored[df_scored['SCORE'] >= self.config.min_score]
        if filtered_df.empty:
            df_scored.loc[:, "SELECTION_STATUS"] = "rejected"
            df_scored.loc[:, "SELECTION_REASON"] = (
                f"score_below_min_score:{self.config.min_score}"
            )
            return pd.DataFrame(), df_scored

        below_min_mask = df_scored["SCORE"] < self.config.min_score
        df_scored.loc[below_min_mask, "SELECTION_STATUS"] = "rejected"
        df_scored.loc[below_min_mask, "SELECTION_REASON"] = (
            f"score_below_min_score:{self.config.min_score}"
        )

        sorted_df = order_candidates(filtered_df, [("SCORE", False)])
        return self._select_in_order(sorted_df, df_scored)

    def _select_in_order(
        self, sorted_df: pd.DataFrame, df_scored: pd.DataFrame
    ) -> Tuple[pd.DataFrame, pd.DataFrame]:
        """Siralanmis adaylara num_records ve cesitlilik limitlerini uygula."""
        selected_records = []
        selected_indices = []
        station_counts = {}
        event_counts = {}

        for idx, record in sorted_df.iterrows():
            if len(selected_records) >= self.config.num_records:
                df_scored.at[idx, "SELECTION_STATUS"] = "rejected"
                df_scored.at[idx, "SELECTION_REASON"] = (
                    f"num_records_limit:{self.config.num_records}"
                )
                break

            station = record.get('STATION', '')
            event = self._event_key(record)

            if station_counts.get(station, 0) >= self.config.max_per_station:
                df_scored.at[idx, "SELECTION_STATUS"] = "rejected"
                df_scored.at[idx, "SELECTION_REASON"] = (
                    f"max_per_station:{self.config.max_per_station}"
                )
                continue

            if event_counts.get(event, 0) >= self.config.max_per_event:
                df_scored.at[idx, "SELECTION_STATUS"] = "rejected"
                df_scored.at[idx, "SELECTION_REASON"] = (
                    f"max_per_event:{self.config.max_per_event}"
                )
                continue

            selected_records.append(record)
            selected_indices.append(idx)
            station_counts[station] = station_counts.get(station, 0) + 1
            event_counts[event] = event_counts.get(event, 0) + 1

        df_scored.loc[selected_indices, "SELECTION_STATUS"] = "selected"
        df_scored.loc[selected_indices, "SELECTION_REASON"] = "selected"

        remaining_mask = df_scored["SELECTION_STATUS"].eq("not_evaluated")
        df_scored.loc[remaining_mask, "SELECTION_STATUS"] = "rejected"
        df_scored.loc[remaining_mask, "SELECTION_REASON"] = (
            f"num_records_limit:{self.config.num_records}"
        )

        selected_df = df_scored.loc[selected_indices].copy()
        return selected_df, df_scored

    def get_name(self) -> str:
        return str(self.config.design_code.value)

select_and_score(df, criteria)

Kayıtları puanla ve seç.

Parameters:

Name Type Description Default
df DataFrame

Puanlanacak veri seti

required
criteria SearchCriteria

Kullanıcının girdiği arama kriterleri ve ağırlıklar

required

Returns:

Type Description
Tuple[DataFrame, DataFrame]

Tuple[pd.DataFrame, pd.DataFrame]: Seçilen kayıtlar ve tüm kayıtların puanlı hali

Source code in src/selection_service/processing/strategies/base.py
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
def select_and_score(self, df: pd.DataFrame, criteria: SearchCriteria) -> Tuple[pd.DataFrame, pd.DataFrame]:
    """ Kayıtları puanla ve seç. 

    Args:
        df (pd.DataFrame): Puanlanacak veri seti
        criteria (SearchCriteria): Kullanıcının girdiği arama kriterleri ve ağırlıklar

    Returns:
        Tuple[pd.DataFrame, pd.DataFrame]: Seçilen kayıtlar ve tüm kayıtların puanlı hali
    """
    if df.empty:
        return pd.DataFrame(), pd.DataFrame()

    scored_df = df.copy()

    # Vektörize işlem yerine apply kullanıyoruz (karmaşık mantık için daha güvenli)
    # Performans gerekirse numpy ile vektörize edilebilir.
    score_results = scored_df.apply(
        lambda row: self._calculate_score_breakdown(row, criteria), axis=1
    )
    scored_df['SCORE'] = score_results.apply(lambda item: item[0])
    scored_df['SCORE_BREAKDOWN'] = score_results.apply(lambda item: item[1])

    # Hicbir kriter icin hedef yoksa (or. yalnizca min_magnitude) skor
    # anlamsizdir (hepsi 0): min_score uygulanmaz, varsayilan siralama
    # (buyuk MAGNITUDE, sonra kucuk mesafe) kullanilir.
    selected_df, scored_df = self._apply_selection_rules_with_reasons(
        scored_df, has_targets=criteria.has_scoring_targets()
    )
    return selected_df, scored_df

selection_service.processing.strategies.TBDYSelectionStrategy

Bases: BaseSelectionStrategy

TBDY 2018 seçim stratejisi

Source code in src/selection_service/processing/strategies/tbdy.py
13
14
15
16
class TBDYSelectionStrategy(BaseSelectionStrategy):
    """TBDY 2018 seçim stratejisi"""
    def get_name(self) -> str:
        return "TBDY_2018_Gaussian"

selection_service.processing.strategies.TBDY2018ConstraintStrategy

Bases: BaseSelectionStrategy

Constraint-first selection with explicit error metrics and diversity limits.

Source code in src/selection_service/processing/strategies/tbdy.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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
class TBDY2018ConstraintStrategy(BaseSelectionStrategy):
    """Constraint-first selection with explicit error metrics and diversity limits."""

    def get_name(self) -> str:
        return "TBDY_2018_Constraint"

    def select_and_score(
        self, df: pd.DataFrame, criteria: SearchCriteria
    ) -> Tuple[pd.DataFrame, pd.DataFrame]:
        if df.empty:
            return pd.DataFrame(), pd.DataFrame()

        scored_df = df.copy()
        evaluation = scored_df.apply(
            lambda row: self._evaluate_record(row, criteria), axis=1
        )
        scored_df["HARD_FILTERS"] = evaluation.apply(lambda item: item["hard_filters"])
        scored_df["ERROR_METRICS"] = evaluation.apply(lambda item: item["error_metrics"])
        scored_df["ERROR_TOTAL"] = evaluation.apply(lambda item: item["error_total"])
        scored_df["SCORE"] = evaluation.apply(lambda item: item["fit_score"])
        scored_df["SCORE_BREAKDOWN"] = evaluation.apply(lambda item: item["error_metrics"])
        scored_df["SELECTION_STATUS"] = "not_evaluated"
        scored_df["SELECTION_REASON"] = ""
        self._add_strategy_columns(scored_df)

        failed_mask = scored_df["HARD_FILTERS"].apply(
            lambda filters: any(item["status"] == "failed" for item in filters)
        )
        scored_df.loc[failed_mask, "SELECTION_STATUS"] = "rejected"
        scored_df.loc[failed_mask, "SELECTION_REASON"] = scored_df.loc[
            failed_mask, "HARD_FILTERS"
        ].apply(self._format_filter_reasons)

        candidate_df = self._candidate_order(scored_df.loc[~failed_mask])
        selected_indices = self._select_diverse_candidates(candidate_df, scored_df)

        scored_df.loc[selected_indices, "SELECTION_STATUS"] = "selected"
        scored_df.loc[selected_indices, "SELECTION_REASON"] = "selected"

        remaining_mask = scored_df["SELECTION_STATUS"].eq("not_evaluated")
        scored_df.loc[remaining_mask, "SELECTION_STATUS"] = "rejected"
        scored_df.loc[remaining_mask, "SELECTION_REASON"] = (
            f"num_records_limit:{self.config.num_records}"
        )

        selected_df = scored_df.loc[selected_indices].copy()
        return selected_df, scored_df

    def _add_strategy_columns(self, scored_df: pd.DataFrame) -> None:
        """Hook for strategy-specific ranking columns."""

    def _candidate_order(self, candidate_df: pd.DataFrame) -> pd.DataFrame:
        """Return candidates in preferred selection order.

        Hedef varsa: ``ERROR_TOTAL`` artan, ``SCORE`` azalan (esitlikte buyuk
        MAGNITUDE, sonra kucuk mesafe). Hicbir kriter icin hedef yoksa
        (tum ``ERROR_TOTAL`` = inf, or. yalnizca ``min_magnitude``) varsayilan
        duzen: buyuk MAGNITUDE once, sonra kucuk mesafe (Rjb > Rrup > Repi).
        """
        if candidate_df.empty:
            return candidate_df
        if candidate_df["ERROR_TOTAL"].apply(math.isinf).all():
            return order_candidates(candidate_df)
        return order_candidates(
            candidate_df, [("ERROR_TOTAL", True), ("SCORE", False)]
        )

    def _evaluate_record(
        self, record: pd.Series, criteria: SearchCriteria
    ) -> Dict[str, Any]:
        hard_filters = self._hard_filter_results(record, criteria)
        error_metrics = self._error_metrics(record, criteria)
        active_errors = [
            item["normalized_error"]
            for item in error_metrics
            if item["status"] in ("active", "missing")
        ]
        error_total = (
            sum(active_errors) / len(active_errors)
            if active_errors
            else float("inf")
        )
        fit_score = 0.0 if math.isinf(error_total) else 100.0 / (1.0 + error_total)
        return {
            "hard_filters": hard_filters,
            "error_metrics": error_metrics,
            "error_total": error_total,
            "fit_score": fit_score,
        }

    def _hard_filter_results(
        self, record: pd.Series, criteria: SearchCriteria
    ) -> List[Dict[str, Any]]:
        results: List[Dict[str, Any]] = []
        for key, config in SCORING_MAP.items():
            column = config["column"]
            if config["type"] == "numeric":
                min_val = criteria._get_range_value("min", key)
                max_val = criteria._get_range_value("max", key)
                if min_val is None and max_val is None:
                    continue
                value = record.get(column)
                status = "passed"
                reason = ""
                if value is None or pd.isna(value):
                    status = "failed"
                    reason = f"missing:{column}"
                elif min_val is not None and value < min_val:
                    status = "failed"
                    reason = f"{key}_below_min:{min_val}"
                elif max_val is not None and value > max_val:
                    status = "failed"
                    reason = f"{key}_above_max:{max_val}"
                results.append({
                    "criterion": key,
                    "column": column,
                    "status": status,
                    "value": None if value is None or pd.isna(value) else value,
                    "min": min_val,
                    "max": max_val,
                    "reason": reason,
                })
            elif key == "mechanism":
                targets = criteria.get_mechanism_targets()
                if not targets:
                    continue
                value = record.get(column)
                match = self._categorical_score(value, targets)
                status = "passed" if match > 0 else "failed"
                results.append({
                    "criterion": key,
                    "column": column,
                    "status": status,
                    "value": value,
                    "target": targets,
                    "reason": "" if status == "passed" else "mechanism_mismatch",
                })
        return results

    def _error_metrics(
        self, record: pd.Series, criteria: SearchCriteria
    ) -> List[Dict[str, Any]]:
        metrics: List[Dict[str, Any]] = []
        for key, config in SCORING_MAP.items():
            column = config["column"]
            if key == "mechanism":
                targets = criteria.get_mechanism_targets()
                if not targets:
                    continue
                value = record.get(column)
                match = self._categorical_score(value, targets)
                metrics.append({
                    "criterion": key,
                    "column": column,
                    "status": "active",
                    "target": targets,
                    "value": value,
                    "absolute_error": 0.0 if match > 0 else 1.0,
                    "normalized_error": 0.0 if match == 1.0 else 0.3 if match > 0 else 1.0,
                    "match": match,
                })
                continue

            target = criteria.get_effective_target(key)
            if target is None:
                continue
            value = record.get(column)
            if value is None or pd.isna(value):
                metrics.append({
                    "criterion": key,
                    "column": column,
                    "status": "missing",
                    "target": target,
                    "value": None,
                    "absolute_error": None,
                    "normalized_error": 1.0,
                })
                continue

            absolute_error = abs(float(value) - float(target))
            scale = self._error_scale(criteria, key, target)
            metrics.append({
                "criterion": key,
                "column": column,
                "status": "active",
                "target": target,
                "value": value,
                "absolute_error": absolute_error,
                "normalized_error": absolute_error / scale,
                "scale": scale,
            })
        return metrics

    def _error_scale(
        self, criteria: SearchCriteria, key: str, target: float
    ) -> float:
        """Hata olcegi: iki tarafli aralik varsa genisligi, yoksa hedefin %10'u.

        ``target`` her zaman ``get_effective_target`` sonucudur (acik target_X
        ya da aralik ortasi); tek tarafli sinir hedef olusturmaz.
        """
        min_val = criteria._get_range_value("min", key)
        max_val = criteria._get_range_value("max", key)
        if min_val is not None and max_val is not None and max_val > min_val:
            return max_val - min_val
        return max(abs(float(target)) * 0.1, 1.0)

    def _format_filter_reasons(self, filters: List[Dict[str, Any]]) -> str:
        reasons = [
            item["reason"]
            for item in filters
            if item["status"] == "failed" and item.get("reason")
        ]
        return ";".join(reasons) if reasons else "hard_filter_failed"

    def _select_diverse_candidates(
        self, candidate_df: pd.DataFrame, scored_df: pd.DataFrame
    ) -> List[Any]:
        selected_indices: List[Any] = []
        station_counts: Dict[Any, int] = {}
        event_counts: Dict[Any, int] = {}

        for idx, record in candidate_df.iterrows():
            if len(selected_indices) >= self.config.num_records:
                scored_df.at[idx, "SELECTION_STATUS"] = "rejected"
                scored_df.at[idx, "SELECTION_REASON"] = (
                    f"num_records_limit:{self.config.num_records}"
                )
                continue

            station = record.get("STATION", "")
            event = self._event_key(record)
            if station_counts.get(station, 0) >= self.config.max_per_station:
                scored_df.at[idx, "SELECTION_STATUS"] = "rejected"
                scored_df.at[idx, "SELECTION_REASON"] = (
                    f"max_per_station:{self.config.max_per_station}"
                )
                continue
            if event_counts.get(event, 0) >= self.config.max_per_event:
                scored_df.at[idx, "SELECTION_STATUS"] = "rejected"
                scored_df.at[idx, "SELECTION_REASON"] = (
                    f"max_per_event:{self.config.max_per_event}"
                )
                continue

            selected_indices.append(idx)
            station_counts[station] = station_counts.get(station, 0) + 1
            event_counts[event] = event_counts.get(event, 0) + 1

        return selected_indices

selection_service.processing.strategies.ConstraintSelectionStrategy

Bases: TBDY2018ConstraintStrategy

Backward-compatible name for TBDY2018ConstraintStrategy.

Source code in src/selection_service/processing/strategies/tbdy.py
270
271
class ConstraintSelectionStrategy(TBDY2018ConstraintStrategy):
    """Backward-compatible name for TBDY2018ConstraintStrategy."""

selection_service.processing.strategies.EurocodeSelectionStrategy

Bases: BaseSelectionStrategy

Eurocode 8 seçim stratejisi

Source code in src/selection_service/processing/strategies/eurocode.py
 8
 9
10
11
12
13
14
class EurocodeSelectionStrategy(BaseSelectionStrategy):
    """Eurocode 8 seçim stratejisi"""

    def _calculate_score(self, record: pd.Series, target_params: Dict[str, Any]) -> float:
        """Eurocode 8'e göre puan hesapla"""
        # Eurocode spesifik implementasyon
        return 0.0  # Implementasyon

selection_service.processing.mappers

IColumnMapper

Bases: Protocol

Kolon eşleme interface'i

Source code in src/selection_service/processing/mappers.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class IColumnMapper(Protocol):
    """Kolon eşleme interface'i"""

    def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Maps the columns of the given DataFrame to a standardized format.

        Args:
            df (pd.DataFrame): The input DataFrame whose columns need to be mapped.

        Returns:
            pd.DataFrame: A DataFrame with columns mapped to the standard format.
        """
        pass

map_columns(df)

Maps the columns of the given DataFrame to a standardized format.

Parameters:

Name Type Description Default
df DataFrame

The input DataFrame whose columns need to be mapped.

required

Returns:

Type Description
DataFrame

pd.DataFrame: A DataFrame with columns mapped to the standard format.

Source code in src/selection_service/processing/mappers.py
16
17
18
19
20
21
22
23
24
25
26
def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
    """
    Maps the columns of the given DataFrame to a standardized format.

    Args:
        df (pd.DataFrame): The input DataFrame whose columns need to be mapped.

    Returns:
        pd.DataFrame: A DataFrame with columns mapped to the standard format.
    """
    pass

BaseColumnMapper

Bases: IColumnMapper, ABC

Temel kolon eşleyici sınıfı

Source code in src/selection_service/processing/mappers.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class BaseColumnMapper(IColumnMapper, ABC):
    """Temel kolon eşleyici sınıfı"""

    def __init__(self, column_mappings: Dict[str, str], **kwargs):
        #
        self.column_mappings = column_mappings

    def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        """Temel eşleme işlemi"""
        df = df.copy()
        df = df.rename(columns=self.column_mappings)
        return self._ensure_standard_columns(df)

    def _ensure_standard_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        """Eksik standart kolonları ekle"""
        for col in STANDARD_COLUMNS:
            if col not in df.columns:
                df[col] = None
        return df[STANDARD_COLUMNS]

map_columns(df)

Temel eşleme işlemi

Source code in src/selection_service/processing/mappers.py
36
37
38
39
40
def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
    """Temel eşleme işlemi"""
    df = df.copy()
    df = df.rename(columns=self.column_mappings)
    return self._ensure_standard_columns(df)

AFADColumnMapper

Bases: BaseColumnMapper

AFAD mapper

Source code in src/selection_service/processing/mappers.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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
class AFADColumnMapper(BaseColumnMapper):
    """AFAD mapper"""

    def __init__(self, **kwargs):
        mappings = {
            "waveformId"                : "RSN"           ,
            "eventId"                   : "EVENT"         ,
            "mvalue"                    : "MAGNITUDE"     ,
            "mtype"                     : "MAGNITUDE_TYPE",
            "rjb"                       : "RJB(km)"       , 
            "rrup"                      : "RRUP(km)"      ,
            "repi"                      : "REPI(km)"      , 
            "rhyp"                      : "RHYP(km)"      ,   
            "relatedEarthquakeLatitude" : "HYPO_LAT"      ,   
            "relatedEarthquakeLongitude": "HYPO_LON"      ,
            "stationCode"               : "SSN"           , #Station Sequence Number olarak kullanacağız, bu kod üzerinden ilgili depreme ait kayıtlar listeleniyor.  
            "stationId"                 : "STATION_ID"    ,  
            "relatedStationLatitude"    : "STATION_LAT"   ,   
            "relatedStationLongitude"   : "STATION_LON"   ,    
            "pga"                       : "PGA(cm2/sec)"  ,    
            "pgv"                       : "PGV(cm/sec)"   ,    
            "pgd"                       : "PGD(cm)"       ,        
            "relatedStrike1"            : "STRIKE1"       ,      
            "relatedDip1"               : "DIP1"          ,     
            "relatedRake1"              : "RAKE1"         ,  
            "relatedStrike2"            : "STRIKE2"       ,   
            "relatedDip2"               : "DIP2"          ,
            "relatedRake2"              : "RAKE2"         ,
            "t90e"                      : "T90_E",
            "t90n"                      : "T90_N", 
            "t90u"                      : "T90_U"
        }

            # None                        :      "HYPO_DEPTH(km)",   
            # None                        :      "FAULT_NAME"    ,   
            # None                        :      "SLIP_RATE"     , 
            # None,                       :      "LOWFREQ(Hz)"   ,
            # "t90e"                      :      "T90_avg(sec)"  ,
            # "stationId"                 :      "SSN"           , 

        super().__init__(mappings)
        self.station_df = self._build_station_info_df()

    def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        """AFAD'a özel ek işlemler"""
        df = df.copy()

        # 1. YEAR dönüşümü
        if "eventDate" in df.columns:
            df["YEAR"] = df["eventDate"].str[:4].astype(int)

        # 2. Özel alanlar için işlemler
        df = self._handle_record_filenames(df)
        df = self._handle_station_infos(df)
        df = self._handle_mechanisms(df)
        df = self._handle_t90_duration(df)
        df = self._handle_depth(df)

        df = super().map_columns(df) #hali hazırdaki kolonların ismi değiştirilecek ve standart kolonlar sadece alınacak   
        return df

    # AFAD yanıtında derinliği taşıyabilecek alanlar (öncelik sırasıyla).
    _DEPTH_FIELDS = (
        "relatedDepth",  # canlı AFAD yanıtında doğrulandı
        "earthquakeDepth",
        "relatedEarthquakeDepth",
        "eventDepth",
        "hypocenterDepth",
        "depth",
    )

    def _handle_depth(self, df: pd.DataFrame) -> pd.DataFrame:
        """HYPO_DEPTH(km) değerini doldur.

        1. Yanıttaki açık derinlik alanı (varsa),
        2. yoksa rhyp ve repi'den: sqrt(rhyp² - repi²).
        """
        depth = pd.Series(float("nan"), index=df.index, dtype="float64")
        for field in self._DEPTH_FIELDS:
            if field in df.columns:
                depth = depth.fillna(pd.to_numeric(df[field], errors="coerce"))
        if "rhyp" in df.columns and "repi" in df.columns:
            rhyp = pd.to_numeric(df["rhyp"], errors="coerce")
            repi = pd.to_numeric(df["repi"], errors="coerce")
            derived = (rhyp**2 - repi**2).clip(lower=0) ** 0.5
            depth = depth.fillna(derived.where(rhyp.notna() & repi.notna()))
        df["HYPO_DEPTH(km)"] = depth
        return df

    def _handle_record_filenames(self, df: pd.DataFrame) -> pd.DataFrame:
        """Record filename'leri işle"""
        if "recordFilename" in df.columns:
            df["FILE_NAME_H1"] = df["recordFilename"]
            df["FILE_NAME_H2"] = df["recordFilename"] 
            df["FILE_NAME_V"] = df["recordFilename"]
        return df

    def _handle_station_infos(self, df: pd.DataFrame) -> pd.DataFrame:
        """İstasyon bilgilerini işle"""
        if "stationCode" in df.columns:
            # İstasyon kodlarını temizle
            df["stationCode"] = df["stationCode"].astype(str).str.strip()

            # Dict ile hızlı lookup
            vs30_map = dict(zip(self.station_df["Code"], self.station_df["Vs30"]))
            location_map = dict(zip(self.station_df["Code"], self.station_df["Location"]))
            # station_lat_map = dict(zip(self.station_df["Code"], self.station_df["Latitude"]))
            # station_lon_map = dict(zip(self.station_df["Code"], self.station_df["Longitude"]))
            # station_id_map  = dict(zip(self.station_df["Code"], self.station_df["Location"]))

            # Eşleme yap
            df["VS30(m/s)"] = df["stationCode"].map(vs30_map).fillna(0.0)
            df["STATION"] = df["stationCode"].map(location_map).fillna("")

            # SSN için stationId kullan
            # if "STATION_ID" in df.columns:
            #     df["SSN"] = df["STATION_ID"]

        return df

    def _handle_mechanisms(self, df: pd.DataFrame) -> pd.DataFrame:
        """Mekanizma bilgisini işle"""

        # Eksik kolonları 0 ile doldurarak oluştur
        for col in ["relatedDip1", "relatedRake1", "relatedDip2", "relatedRake2"]:
            if col not in df.columns:
                df[col] = 0

        # Vektörize hesaplama için
        dip1 = df.get("relatedDip1", 0)
        rake1 = df.get("relatedRake1", 0) 
        dip2 = df.get("relatedDip2", 0)
        rake2 = df.get("relatedRake2", 0)

        # Her satır için mekanizma hesapla
        mechanisms = []
        for i in range(len(df)):
            mechanism = self._classify_fault_planes(
                dip1.iloc[i] if not pd.isna(dip1.iloc[i]) else 0,
                rake1.iloc[i] if not pd.isna(rake1.iloc[i]) else 0,
                dip2.iloc[i] if not pd.isna(dip2.iloc[i]) else 0, 
                rake2.iloc[i] if not pd.isna(rake2.iloc[i]) else 0
            )
            mechanisms.append(mechanism)

        df["MECHANISM"] = mechanisms
        return df

    def _handle_t90_duration(self, df: pd.DataFrame) -> pd.DataFrame:
        """T90 sürelerini işle ve PEER ile ortak kolona (T90_avg(sec)) yaz."""
        t90_cols = ["t90e", "t90n", "t90u"]
        if all(col in df.columns for col in t90_cols):
            df["T90_avg(sec)"] = df[t90_cols].mean(axis=1)
        elif any(col in df.columns for col in t90_cols):
            present = [c for c in t90_cols if c in df.columns]
            df["T90_avg(sec)"] = df[present].mean(axis=1)
        else:
            df["T90_avg(sec)"] = None
        return df

    # ------- STATION UTILITY FUNCTIONS ------------

    #BUG NOTU: Formül yanlış hesaplıyor tekrardan kontrol etmek gerek testten geçemedi.
    def _haversine(self, lat1, lon1, lat2, lon2):
        """
        #DEPRECATED: Daha önceki versiyonlarda AFAD verisindeki bazı istasyonların Vs30 değerleri eksikti ve bu fonksiyonla yakınlardaki istasyonların Vs30 değerlerini atayarak dolduruyorduk. Ancak artık AFAD verisinde Vs30 bilgisi de gelmeye başladı, bu yüzden bu fonksiyonu kullanmayı bıraktık. Yine de ileride benzer bir ihtiyaç olursa diye kodu silmek yerine yorum satırı yaptık.
        İki nokta arasındaki mesafeyi km cinsinden döndürür (Haversine formülü).
        """
        R = 6371.0  # Dünya yarıçapı (km)

        lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
        dlat = lat2 - lat1
        dlon = lon2 - lon1
        a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
        c = 2 * atan2(sqrt(a), sqrt(1-a))

        return R * c

    # AFADDataProvider'da istasyon eşleme iyileştirmesi
    @lru_cache(maxsize=1)
    def _build_station_info_df(self, max_distance_km: float = 30.0) -> pd.DataFrame:
        """İstasyon bilgilerini yükler ve eksik Vs30'ları doldurur (30 km yarıçap içinde)
            - Bu fonksiyonun amacı, AFAD verisindeki istasyonların Vs30 bilgilerini mümkün olduğunca doldurmak ve böylece seçim stratejilerinin daha sağlıklı çalışmasını sağlamaktır. Ancak, bu işlem her zaman mükemmel sonuçlar vermeyebilir, çünkü bazı istasyonların çevresinde hiç Vs30 bilgisi olmayan istasyonlar olabilir veya mesafe kriteri nedeniyle doldurulamayan istasyonlar olabilir. Bu nedenle, sonuçları dikkatli bir şekilde değerlendirmek önemlidir. 
            - Eksik Vs30'ları doldurmak için KDTree kullanarak hızlı bir şekilde en yakın istasyonu buluruz.
            - Eğer en yakın istasyonun mesafesi max_distance_km'den büyükse Vs30'ı doldurmak yerine 0.0 olarak bırakırız.
            - Bu fonksiyon lru_cache ile cache'lenmiştir, böylece ilk çağrıda dosya yüklenir ve işlenir, sonraki çağrılarda aynı DataFrame döndürülür. Eğer istasyon dosyası güncellenirse veya farklı bir dosya kullanmak istenirse cache temizlenebilir veya max_distance_km parametresi değiştirilebilir.
            - Not: Bu fonksiyonun çalışması için "stations.xlsx" dosyasının doğru formatta ve gerekli kolonları içerecek şekilde hazırlanmış olması gerekmektedir. Dosyada "Code", "Vs30", "Latitude", "Longitude" ve "Location" gibi kolonların bulunması beklenir.
            - Eğer dosya yüklenemez veya işlenemezse, fonksiyon boş bir DataFrame döndürecektir.

        """
        try:
            df = load_excel("stations.xlsx")
            df["Code"] = df["Code"].astype(str).str.strip()

            # Eksik Vs30'ları doldurma - vektörize versiyon
            missing_mask = (df["Vs30"].isna()) | (df["Vs30"] == 0)
            if missing_mask.any():
                valid_stations = df[~missing_mask]
                if not valid_stations.empty:
                    # KDTree ile en yakın komşu arama (çok daha hızlı)
                    from scipy.spatial import KDTree

                    valid_coords = valid_stations[['Latitude', 'Longitude']].values
                    tree = KDTree(valid_coords)

                    missing_coords = df.loc[missing_mask, ['Latitude', 'Longitude']].values
                    distances, indices = tree.query(missing_coords, k=1)

                    for i, (idx, distance) in enumerate(zip(missing_mask[missing_mask].index, distances)):
                        if distance <= max_distance_km:
                            df.loc[idx, 'Vs30'] = valid_stations.iloc[indices[i]]['Vs30']
                        else:
                            df.loc[idx, 'Vs30'] = 0.0
            return df

        except Exception as e:
            print(f"İstasyon dosyası yükleme hatası: {e}")
            return pd.DataFrame()    

    # ------- FAULT UTILITY FUNCTIONS ------------

    def _classify_fault_type(self, dip: float, rake: float) -> str:
        """
        Strike, dip ve rake değerlerine göre fay türünü sınıflandırır.
        Basit kurallar: 
        - Rake ~ 0° veya 180°  -> doğrultu atım (strike-slip)
        - Rake ~ +90°          -> ters fay (reverse)
        - Rake ~ -90°          -> normal fay (normal)
        - Aradaki açılar       -> oblik fay (oblique)
        - Eğer ±60 <= rake <= ±120 civarı ama dip < 30° → oblique olarak kabul ediyoruz.

        Args:
            ###strike (float): Fayın doğrultu açısı (0-360)
            dip (float): Fayın eğim açısı (0-90)
            rake (float): Fayın kayma açısı (-180, 180)

        Returns:
            str: "StrikeSlip" | "Normal" | "Reverse"| "Reverse/Oblique"| "Normal/Oblique" | "Oblique"
        """

        if pd.isna(dip) or pd.isna(rake):
            return "Unknown"

        rake_norm = ((rake + 180) % 360) - 180  # -180..180 normalize et
        dip = float(dip)

        if -30 <= rake_norm <= 30 or 150 <= abs(rake_norm) <= 180:
            return MECHANISM_MAP[0]
        elif 60 <= rake_norm <= 120:
            return MECHANISM_MAP[2] if dip >= 30 else MECHANISM_MAP[3]
        elif -120 <= rake_norm <= -60:
            return MECHANISM_MAP[1] if dip >= 30 else MECHANISM_MAP[4]
        else:
            return MECHANISM_MAP[5]

    def _classify_fault_planes(self, dip1, rake1, dip2, rake2) -> str:
        """
        İki fay düzlemi için sınıflandırma yapar.
        Eğer aynı türse tek değer, farklı türse birleştirilmiş değer döner.
        """
        f1 = self._classify_fault_type(dip1, rake1)
        f2 = self._classify_fault_type(dip2, rake2)

        return f1 if f1 == f2 else f"{f1}-{f2}"

map_columns(df)

AFAD'a özel ek işlemler

Source code in src/selection_service/processing/mappers.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
    """AFAD'a özel ek işlemler"""
    df = df.copy()

    # 1. YEAR dönüşümü
    if "eventDate" in df.columns:
        df["YEAR"] = df["eventDate"].str[:4].astype(int)

    # 2. Özel alanlar için işlemler
    df = self._handle_record_filenames(df)
    df = self._handle_station_infos(df)
    df = self._handle_mechanisms(df)
    df = self._handle_t90_duration(df)
    df = self._handle_depth(df)

    df = super().map_columns(df) #hali hazırdaki kolonların ismi değiştirilecek ve standart kolonlar sadece alınacak   
    return df

PEERColumnMapper

Bases: BaseColumnMapper

PEER NGA-West2 kolon eşleyici.

NGA-West2_flatfile.csv dosyası zaten ortak (STANDARD_COLUMNS) formatında geliyor — eski flatfile kolon isimlerine dayalı rename gereksiz.

Yapılan işlemler
  1. CSV kolonları identity mapping ile alınır (rename yok).
  2. PGA birimi g → cm/s² dönüştürülür.
  3. 5-95%Duration(sec) → T90_avg(sec) olarak adlandırılır (AFAD ile ortak kolon adı).
  4. ENDPOINTSOURCE sütunu None olarak eklenir (AFAD'a özgü; PEER için indirme linki yok).
Source code in src/selection_service/processing/mappers.py
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
class PEERColumnMapper(BaseColumnMapper):
    """PEER NGA-West2 kolon eşleyici.

    NGA-West2_flatfile.csv dosyası zaten ortak (STANDARD_COLUMNS) formatında
    geliyor — eski flatfile kolon isimlerine dayalı rename gereksiz.

    Yapılan işlemler:
      1. CSV kolonları identity mapping ile alınır (rename yok).
      2. PGA birimi g → cm/s² dönüştürülür.
      3. 5-95%Duration(sec) → T90_avg(sec) olarak adlandırılır
         (AFAD ile ortak kolon adı).
      4. ENDPOINTSOURCE sütunu None olarak eklenir
         (AFAD'a özgü; PEER için indirme linki yok).
    """

    def __init__(self, **kwargs):
        # CSV zaten standart formatta — rename mapping boş
        super().__init__(column_mappings={})

    def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        df = df.copy()

        # 1. PGA birimi: g → cm/s²
        if "PGA(g)" in df.columns:
            df["PGA(cm2/sec)"] = df["PGA(g)"] * 980.665
            df = df.drop(columns=["PGA(g)"], errors="ignore")

        # 2. T90: "5-95%Duration(sec)" → "T90_avg(sec)" (AFAD ile ortak ad)
        if "5-95%Duration(sec)" in df.columns:
            df["T90_avg(sec)"] = df["5-95%Duration(sec)"]
            df = df.drop(columns=["5-95%Duration(sec)"], errors="ignore")

        # 3. AriasIntensity rename (STANDARD_COLUMNS adına uygun)
        if "AriasIntensity(m/sec)" in df.columns:
            df["ARIAS_INTENSITY(m/sec)"] = df["AriasIntensity(m/sec)"]
            df = df.drop(columns=["AriasIntensity(m/sec)"], errors="ignore")

        # 4. STATION_LAT / STATION_LON: CSV'de "STATION_LAT", "STATION_LON" var
        #    STANDARD_COLUMNS ile örtüşüyor, dokunma.

        # 5. ENDPOINTSOURCE: PEER için indirme linki yok
        df["ENDPOINTSOURCE"] = None

        # 6. Standart kolonlara hizala (eksikleri None ile doldur, fazlaları at)
        return self._ensure_standard_columns(df)

FDSNColumnMapper

Bases: BaseColumnMapper

FDSN kolon eşleyici

Source code in src/selection_service/processing/mappers.py
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
class FDSNColumnMapper(BaseColumnMapper):
    """FDSN kolon eşleyici"""

    def __init__(self, **kwargs):
        mappings = {
            # FDSN spesifik kolon eşlemeleri buraya eklenebilir
            "Record Sequence Number"                    : "RSN",
            "Earthquake Name"                           : "EVENT",
            "YEAR"                                      : "YEAR", 
            "Earthquake Magnitude"                      : "MAGNITUDE",
            "Magnitude Type"                            : "MAGNITUDE_TYPE",
            "Station Name"                              : "STATION",
            "Station Sequence Number"                   : "SSN",
            "Station ID  No."                           : "STATION_ID",
            "Station Latitude"                          : "STATIN_LAT",
            "Station Longitude"                         : "STATIN_LON",
            "Vs30 (m/s) selected for analysis"          : "VS30(m/s)",
            "Strike (deg)"                              : "STRIKE1",
            "Dip (deg)"                                 : "DIP1",
            "Rake Angle (deg)"                          : "RAKE1",
            "Mechanism Based on Rake Angle"             : "MECHANISM",
            "EpiD (km)"                                 : "EPICENTER_DEPTH(km)",
            "HypD (km)"                                 : "HYPOCENTER_DEPTH(km)",
            "Joyner-Boore Dist. (km)"                   : "RJB(km)",
            "ClstD (km)"                                : "RRUP(km)",
            "Hypocenter Latitude (deg)"                 : "HYPO_LAT",
            "Hypocenter Longitude (deg)"                : "HYPO_LON",
            "Hypocenter Depth (km)"                     : "HYPO_DEPTH(km)",
            "Lowest Usable Freq - Ave. Component (Hz)"  : "LOWFREQ(Hz)",
            "File Name (Horizontal 1)"                  : "FILE_NAME_H1",
            "File Name (Horizontal 2)"                  : "FILE_NAME_H2",
            "File Name (Vertical)"                      : "FILE_NAME_V",
            "PGA(g)"                                    : "PGA(cm2/sec)",
            "PGV (cm/sec)"                              : "PGV(cm/sec)",
            "PGD (cm)"                                  : "PGD(cm)",
            "5-95%Duration(sec)"                        : "T90(sec)", 
            "AriasIntensity(m/sec)"                     : "ARIAS_INTENSITY(m/sec)"
        }
        super().__init__(mappings)

ESMColumnMapper

Bases: BaseColumnMapper

ESM flatfile (esm-db.eu) kolon esleyici.

  • Buyukluk: Mw, yoksa EMEC Mw, Ms, Ml, Mb (MAGNITUDE_TYPE ile birlikte).
  • PGA/PGV/PGD: RotD50, yoksa iki yatay bilesenin geometrik ortalamasi (PGA cm/s², PGV cm/s, PGD cm). T90 ve Arias iki yatay bilesenin ortalamasi; Arias cm/s -> m/s.
  • Rjb/Rrup: sonlu fay mesafesi; bos ise (kucuk depremler, nokta kaynak) Repi/Rhyp kullanilir.
  • FILE_NAME_H1 indirme anahtari AG.ISTASYON.KONUM.KANAL; EVENT ESM deprem kimligidir, SSN AG.ISTASYON.
Source code in src/selection_service/processing/mappers.py
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
class ESMColumnMapper(BaseColumnMapper):
    """ESM flatfile (esm-db.eu) kolon esleyici.

    - Buyukluk: Mw, yoksa EMEC Mw, Ms, Ml, Mb (MAGNITUDE_TYPE ile birlikte).
    - PGA/PGV/PGD: RotD50, yoksa iki yatay bilesenin geometrik ortalamasi
      (PGA cm/s², PGV cm/s, PGD cm). T90 ve Arias iki yatay bilesenin ortalamasi;
      Arias cm/s -> m/s.
    - Rjb/Rrup: sonlu fay mesafesi; bos ise (kucuk depremler, nokta kaynak)
      Repi/Rhyp kullanilir.
    - FILE_NAME_H1 indirme anahtari ``AG.ISTASYON.KONUM.KANAL``; EVENT ESM deprem
      kimligidir, SSN ``AG.ISTASYON``.
    """

    MAGNITUDES = (("mw", "Mw"), ("emec_mw", "Mw"), ("ms", "Ms"), ("ml", "Ml"), ("mb", "Mb"))
    MECHANISMS = {"NF": "Normal", "TF": "Reverse", "SS": "StrikeSlip", "O": "Oblique", "U": "Unknown"}

    def __init__(self, **kwargs):
        super().__init__(column_mappings={})

    @staticmethod
    def _num(df: pd.DataFrame, column: str) -> pd.Series:
        if column not in df.columns:
            return pd.Series(np.nan, index=df.index, dtype=float)
        return pd.to_numeric(df[column], errors="coerce")

    @classmethod
    def _horizontal(cls, df: pd.DataFrame, rotd50: str, u: str, v: str) -> pd.Series:
        geomean = np.sqrt(cls._num(df, u).abs() * cls._num(df, v).abs())
        return cls._num(df, rotd50).fillna(geomean)

    @staticmethod
    def _text(df: pd.DataFrame, column: str) -> pd.Series:
        if column not in df.columns:
            return pd.Series("", index=df.index)
        return df[column].fillna("").astype(str).str.strip()

    def map_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        df = df.copy()
        out = pd.DataFrame(index=df.index)
        out["PROVIDER"] = ProviderName.ESM.value
        network, station = self._text(df, "network_code"), self._text(df, "station_code")
        location, channel = self._text(df, "location_code"), self._text(df, "channel_code")
        out["EVENT"] = self._text(df, "esm_event_id")
        out["SSN"] = network + "." + station
        out["STATION"] = station
        out["STATION_ID"] = out["SSN"]
        out["FILE_NAME_H1"] = network + "." + station + "." + location + "." + channel
        out["RSN"] = out["EVENT"] + "|" + out["FILE_NAME_H1"]
        out["YEAR"] = pd.to_numeric(self._text(df, "event_time").str[:4], errors="coerce").astype("Int64")

        magnitude = pd.Series(np.nan, index=df.index, dtype=float)
        magnitude_type = pd.Series(None, index=df.index, dtype=object)
        for column, label in self.MAGNITUDES:
            values = self._num(df, column)
            take = magnitude.isna() & values.notna()
            magnitude[take] = values[take]
            magnitude_type[take] = label
        out["MAGNITUDE"] = magnitude
        out["MAGNITUDE_TYPE"] = magnitude_type

        out["HYPO_LAT"] = self._num(df, "ev_latitude")
        out["HYPO_LON"] = self._num(df, "ev_longitude")
        out["HYPO_DEPTH(km)"] = self._num(df, "ev_depth_km")
        out["STATION_LAT"] = self._num(df, "st_latitude")
        out["STATION_LON"] = self._num(df, "st_longitude")
        out["VS30(m/s)"] = self._num(df, "preferred_vs30_m_s")
        out["RJB(km)"] = self._num(df, "jb_dist").fillna(self._num(df, "epi_dist"))
        out["RRUP(km)"] = self._num(df, "rup_dist").fillna(self._num(df, "hyp_dist"))
        out["STRIKE1"] = self._num(df, "es_strike")
        out["DIP1"] = self._num(df, "es_dip")
        out["RAKE1"] = self._num(df, "es_rake")
        out["MECHANISM"] = self._text(df, "fm_type_code").str.upper().map(self.MECHANISMS).fillna("Unknown")
        out["PGA(cm2/sec)"] = self._horizontal(df, "rotd50_pga", "u_pga", "v_pga")
        out["PGV(cm/sec)"] = self._horizontal(df, "rotd50_pgv", "u_pgv", "v_pgv")
        out["PGD(cm)"] = self._horizontal(df, "rotd50_pgd", "u_pgd", "v_pgd")
        out["T90_avg(sec)"] = pd.concat([self._num(df, "u_t90"), self._num(df, "v_t90")], axis=1).mean(axis=1)
        out["ARIAS_INTENSITY(m/sec)"] = self._num(df, "rotd50_ai") / 100.0
        out["LOWFREQ(Hz)"] = pd.concat([self._num(df, "u_hp"), self._num(df, "v_hp")], axis=1).max(axis=1)
        out["ENDPOINTSOURCE"] = "https://esm-db.eu/#/event/" + out["EVENT"]
        return self._ensure_standard_columns(out)

ColumnMapperFactory

Kolon eşleyici factory sınıfı

Source code in src/selection_service/processing/mappers.py
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
class ColumnMapperFactory:
    """Kolon eşleyici factory sınıfı"""

    # Key olarak .value string kullanılır — farklı instance'lar arasında
    # hash eşleşme sorunu yaşanmaz (OCP: register ile genişletme desteklenir).
    _mappers: dict = {
        ProviderName.AFAD.value: AFADColumnMapper,
        ProviderName.PEER.value: PEERColumnMapper,
        ProviderName.FDSN.value: FDSNColumnMapper,
        ProviderName.ESM.value: ESMColumnMapper,
    }

    @classmethod
    def _key(cls, provider) -> str:
        """Provider → string key dönüşümü."""
        if isinstance(provider, ProviderName):
            return provider.value
        if hasattr(provider, "value"):
            return str(provider.value)
        return str(provider)

    @classmethod
    def get_mapper(cls, provider) -> IColumnMapper:
        """Provider'a göre uygun eşleyiciyi döndür."""
        mapper_class = cls._mappers.get(cls._key(provider))
        if mapper_class is not None:
            return mapper_class()
        return BaseColumnMapper(column_mappings={})

    @classmethod
    def register_mapper(cls, provider, mapper_class: Type[IColumnMapper]) -> None:
        """Yeni eşleyici kaydet (OCP: factory'ye dokunmadan genişletme)."""
        cls._mappers[cls._key(provider)] = mapper_class

    @classmethod
    def create_mapper(cls, provider_Name, **kwargs) -> IColumnMapper:
        if provider_Name == ProviderName.AFAD:
            return AFADColumnMapper(**kwargs)
        if provider_Name == ProviderName.PEER:
            return PEERColumnMapper(**kwargs)
        if provider_Name == ProviderName.FDSN:
            return FDSNColumnMapper(**kwargs)
        if provider_Name == ProviderName.ESM:
            return ESMColumnMapper(**kwargs)
        else:
            return BaseColumnMapper(column_mappings={}, **kwargs)

    # ── Kısa alias'lar (OCP destekli tek API) ─────────────────────

    @classmethod
    def create(cls, provider, **kwargs) -> IColumnMapper:
        """create_mapper() alias — tek giriş noktası."""
        return cls.create_mapper(provider, **kwargs)

    @classmethod
    def register(cls, provider, mapper_class: Type[IColumnMapper]) -> None:
        """register_mapper() alias."""
        cls.register_mapper(provider, mapper_class)

get_mapper(provider) classmethod

Provider'a göre uygun eşleyiciyi döndür.

Source code in src/selection_service/processing/mappers.py
511
512
513
514
515
516
517
@classmethod
def get_mapper(cls, provider) -> IColumnMapper:
    """Provider'a göre uygun eşleyiciyi döndür."""
    mapper_class = cls._mappers.get(cls._key(provider))
    if mapper_class is not None:
        return mapper_class()
    return BaseColumnMapper(column_mappings={})

register_mapper(provider, mapper_class) classmethod

Yeni eşleyici kaydet (OCP: factory'ye dokunmadan genişletme).

Source code in src/selection_service/processing/mappers.py
519
520
521
522
@classmethod
def register_mapper(cls, provider, mapper_class: Type[IColumnMapper]) -> None:
    """Yeni eşleyici kaydet (OCP: factory'ye dokunmadan genişletme)."""
    cls._mappers[cls._key(provider)] = mapper_class

create(provider, **kwargs) classmethod

create_mapper() alias — tek giriş noktası.

Source code in src/selection_service/processing/mappers.py
539
540
541
542
@classmethod
def create(cls, provider, **kwargs) -> IColumnMapper:
    """create_mapper() alias — tek giriş noktası."""
    return cls.create_mapper(provider, **kwargs)

register(provider, mapper_class) classmethod

register_mapper() alias.

Source code in src/selection_service/processing/mappers.py
544
545
546
547
@classmethod
def register(cls, provider, mapper_class: Type[IColumnMapper]) -> None:
    """register_mapper() alias."""
    cls.register_mapper(provider, mapper_class)

selection_service.processing.dedup

Provider'lar arası tekrar eden deprem/kayıt tespiti.

AFAD ve PEER aynı depremi farklı adlarla (ve biraz farklı konum/büyüklükle) döndürebilir. Ortak bir olay kimliği olmadığından eşleştirme sezgiseldir:

  • Olay eşleşmesi: aynı YEAR, büyüklük farkı max_mag_diff içinde ve episantr mesafesi max_event_distance_km içinde. Yalnızca FARKLI provider'ların olayları eşlenir ve eşleşme karşılıklı en yakın olay ile sınırlıdır; böylece aynı provider'daki artçı şoklar birleşmez.
  • Kayıt tekrarı: aynı olay grubunda, farklı provider'lardan gelen ve istasyon konumu max_station_distance_km içinde olan kayıtlar aynı kayıt sayılır.

Hatalı birleştirmenin bedeli düşüktür (olay başına 3 kayıt sınırı biraz daha sıkı uygulanır); eksik birleştirmenin bedeli ise aynı depremin sınırı aşmasıdır. Bu nedenle eşikler bilinçli olarak cömerttir.

valid_coordinates(lat, lon)

Geçerli koordinat maskesi (NaN, -999 ve (0, 0) yer tutucuları hariç).

Source code in src/selection_service/processing/dedup.py
35
36
37
38
39
40
41
def valid_coordinates(lat: pd.Series, lon: pd.Series) -> pd.Series:
    """Geçerli koordinat maskesi (NaN, -999 ve (0, 0) yer tutucuları hariç)."""
    lat = pd.to_numeric(lat, errors="coerce")
    lon = pd.to_numeric(lon, errors="coerce")
    in_range = lat.between(-90, 90) & lon.between(-180, 180)
    placeholder = (lat == 0) & (lon == 0)
    return in_range & ~placeholder & lat.notna() & lon.notna()

haversine_km(lat1, lon1, lat2, lon2)

Büyük daire mesafesi (km); girdiler derece, numpy yayınlama destekli.

Source code in src/selection_service/processing/dedup.py
44
45
46
47
48
49
50
51
def haversine_km(lat1, lon1, lat2, lon2) -> np.ndarray:
    """Büyük daire mesafesi (km); girdiler derece, numpy yayınlama destekli."""
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = (
        np.sin((lat2 - lat1) / 2) ** 2
        + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    )
    return 2 * _EARTH_RADIUS_KM * np.arcsin(np.sqrt(a))

assign_event_groups(df, max_event_distance_km=DEFAULT_MAX_EVENT_DISTANCE_KM, max_mag_diff=DEFAULT_MAX_MAG_DIFF)

Her satır için olay grubu kimliği döndür.

Eşleşen provider'lar arası olaylar aynı G<n> grubunu paylaşır; diğer olaylar <PROVIDER>|<EVENT> olarak kalır (adlar provider'lar arasında çakışmasın diye).

Source code in src/selection_service/processing/dedup.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def assign_event_groups(
    df: pd.DataFrame,
    max_event_distance_km: float = DEFAULT_MAX_EVENT_DISTANCE_KM,
    max_mag_diff: float = DEFAULT_MAX_MAG_DIFF,
) -> pd.Series:
    """Her satır için olay grubu kimliği döndür.

    Eşleşen provider'lar arası olaylar aynı ``G<n>`` grubunu paylaşır; diğer
    olaylar ``<PROVIDER>|<EVENT>`` olarak kalır (adlar provider'lar arasında
    çakışmasın diye).
    """
    required = {"PROVIDER", "EVENT", "YEAR", "MAGNITUDE", "HYPO_LAT", "HYPO_LON"}
    base = df["PROVIDER"].astype(str) + "|" + df["EVENT"].astype(str)
    if not required <= set(df.columns) or df["PROVIDER"].nunique() < 2:
        return base

    events = _unique_events(df)
    parent: Dict[Tuple[str, str], Tuple[str, str]] = {}

    def find(node):
        parent.setdefault(node, node)
        while parent[node] != node:
            parent[node] = parent[parent[node]]
            node = parent[node]
        return node

    providers = list(events["PROVIDER"].unique())
    by_provider = {p: events[events["PROVIDER"] == p].reset_index(drop=True) for p in providers}
    for a_idx, a in enumerate(providers):
        for b in providers[a_idx + 1:]:
            left, right = by_provider[a], by_provider[b]
            forward = _best_matches(left, right, max_event_distance_km, max_mag_diff)
            backward = _best_matches(right, left, max_event_distance_km, max_mag_diff)
            for i, j in forward.items():
                if backward.get(j) == i:  # karşılıklı en yakın
                    ra = (left.at[i, "PROVIDER"], str(left.at[i, "EVENT"]))
                    rb = (right.at[j, "PROVIDER"], str(right.at[j, "EVENT"]))
                    parent[find(ra)] = find(rb)

    members: Dict[Tuple[str, str], List[Tuple[str, str]]] = {}
    for node in list(parent):
        members.setdefault(find(node), []).append(node)
    labels: Dict[Tuple[str, str], str] = {}
    for n, nodes in enumerate(sorted(m for m in members.values() if len(m) > 1), 1):
        for node in nodes:
            labels[node] = f"G{n}"

    keys = list(zip(df["PROVIDER"].astype(str), df["EVENT"].astype(str)))
    return pd.Series(
        [labels.get(k, f"{k[0]}|{k[1]}") for k in keys], index=df.index, name=EVENT_GROUP_COLUMN
    )

find_duplicate_records(df, group_col=EVENT_GROUP_COLUMN, max_station_distance_km=DEFAULT_MAX_STATION_DISTANCE_KM, prefer_provider=None)

Başka provider'daki bir kaydın tekrarı olan satırlar için True.

Aynı olay grubunda ve istasyon konumu max_station_distance_km içindeki kayıtlardan prefer_provider'ınki, verilmediyse DataFrame'de ilk görünen provider'ınki korunur. Provider'lar öncelik sırasıyla işlenir; her biri, önceki provider'ların korunan kayıtlarından kurulan bir KD-ağacında sorgulanır (grup başına O(n log n)).

Source code in src/selection_service/processing/dedup.py
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
def find_duplicate_records(
    df: pd.DataFrame,
    group_col: str = EVENT_GROUP_COLUMN,
    max_station_distance_km: float = DEFAULT_MAX_STATION_DISTANCE_KM,
    prefer_provider: Optional[str] = None,
) -> pd.Series:
    """Başka provider'daki bir kaydın tekrarı olan satırlar için True.

    Aynı olay grubunda ve istasyon konumu ``max_station_distance_km`` içindeki
    kayıtlardan ``prefer_provider``'ınki, verilmediyse DataFrame'de ilk görünen
    provider'ınki korunur. Provider'lar öncelik sırasıyla işlenir; her biri, önceki provider'ların korunan kayıtlarından
    kurulan bir KD-ağacında sorgulanır (grup başına O(n log n)).
    """
    duplicate = pd.Series(False, index=df.index)
    needed = {group_col, "PROVIDER", "STATION_LAT", "STATION_LON"}
    if not needed <= set(df.columns):
        return duplicate

    valid = valid_coordinates(df["STATION_LAT"], df["STATION_LON"])
    providers = list(df["PROVIDER"].astype(str).unique())
    if prefer_provider in providers:
        providers.remove(prefer_provider)
        providers.insert(0, prefer_provider)
    order = {p: n for n, p in enumerate(providers)}
    # Birim küredeki kiriş uzunluğu: 2 * sin(açı / 2)
    chord = 2 * np.sin(max_station_distance_km / _EARTH_RADIUS_KM / 2)

    candidates = df[valid]
    for _, rows in candidates.groupby(group_col):
        if rows["PROVIDER"].nunique() < 2:
            continue
        rank = rows["PROVIDER"].astype(str).map(order).to_numpy()
        points = _unit_vectors(
            pd.to_numeric(rows["STATION_LAT"]).to_numpy(),
            pd.to_numeric(rows["STATION_LON"]).to_numpy(),
        )
        index = rows.index.to_numpy()
        kept = np.zeros(len(rows), dtype=bool)
        for r in sorted(set(rank)):
            current = rank == r
            if kept.any():
                tree = cKDTree(points[kept])
                distance, _ = tree.query(points[current], distance_upper_bound=chord)
                is_dup = np.isfinite(distance)
            else:
                is_dup = np.zeros(int(current.sum()), dtype=bool)
            duplicate.loc[index[current][is_dup]] = True
            kept[np.flatnonzero(current)[~is_dup]] = True
    return duplicate

selection_service.processing.result_handle

Result dataclass

Bases: Generic[T, E]

Source code in src/selection_service/processing/result_handle.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@dataclass
class Result(Generic[T, E]):
    success: bool
    value: Optional[T] = None
    error: Optional[E] = None

    @classmethod
    def ok(cls, value: T) -> 'Result[T, E]':
        """Creates a successful result with the given value."""
        return cls(success=True, value=value)

    @classmethod
    def fail(cls, error: E) -> 'Result[T, E]':
        """Creates a failure result with the given error."""
        return cls(success=False, error=error)

    def unwrap(self) -> T:
        """Returns the value if success, otherwise raises the error."""
        if self.success:
            return self.value
        raise self.error

    def __repr__(self):
        """Custom string representation for debugging"""
        if self.success:
            return f"<Result OK value={type(self.value)}>"
        return f"<Result FAIL error={self.error}>"

ok(value) classmethod

Creates a successful result with the given value.

Source code in src/selection_service/processing/result_handle.py
20
21
22
23
@classmethod
def ok(cls, value: T) -> 'Result[T, E]':
    """Creates a successful result with the given value."""
    return cls(success=True, value=value)

fail(error) classmethod

Creates a failure result with the given error.

Source code in src/selection_service/processing/result_handle.py
25
26
27
28
@classmethod
def fail(cls, error: E) -> 'Result[T, E]':
    """Creates a failure result with the given error."""
    return cls(success=False, error=error)

unwrap()

Returns the value if success, otherwise raises the error.

Source code in src/selection_service/processing/result_handle.py
30
31
32
33
34
def unwrap(self) -> T:
    """Returns the value if success, otherwise raises the error."""
    if self.success:
        return self.value
    raise self.error

__repr__()

Custom string representation for debugging

Source code in src/selection_service/processing/result_handle.py
36
37
38
39
40
def __repr__(self):
    """Custom string representation for debugging"""
    if self.success:
        return f"<Result OK value={type(self.value)}>"
    return f"<Result FAIL error={self.error}>"