disaster_factor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
2
+
3
+ try:
4
+ __version__ = _pkg_version("disaster_factor")
5
+ except PackageNotFoundError:
6
+ # Fallback for editable installs before metadata is available
7
+ __version__ = "0+unknown"
8
+
9
+ __all__ = ["__version__"]
10
+
@@ -0,0 +1,7 @@
1
+ from .cli import main
2
+
3
+ # Module Execution Path
4
+ # Runs when calling `python3 -m disaster_factor` in CLI
5
+ # This is the "Main" entrypoint
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
disaster_factor/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from . import __version__
6
+ from .core import track_disasters
7
+
8
+
9
+ def _default_prog() -> str:
10
+ # Derive a sensible display name from the invoked script/module
11
+ return Path(sys.argv[0]).name or "python"
12
+
13
+
14
+ def _cmd_track(args: argparse.Namespace) -> int:
15
+ track_disasters(debug=getattr(args, "debug", False))
16
+ return 0
17
+
18
+
19
+ def _build_parser(prog: str | None = None) -> argparse.ArgumentParser:
20
+ prog = prog or _default_prog()
21
+ parser = argparse.ArgumentParser(prog=prog, description=(__doc__ or ""))
22
+ parser.add_argument(
23
+ "--version",
24
+ action="version",
25
+ version=f"{prog} {__version__}",
26
+ help="Show version and exit",
27
+ )
28
+
29
+ # Subcommands
30
+ subparsers = parser.add_subparsers(dest="command")
31
+ sp_track = subparsers.add_parser("track", help="Track current disasters")
32
+ sp_track.add_argument(
33
+ "--debug",
34
+ action="store_true",
35
+ help=(
36
+ "Run in debug mode: skip launching the web UI server and just write "
37
+ "affected.csv, prelim.csv, and points.json plus console output."
38
+ ),
39
+ )
40
+ sp_track.set_defaults(func=_cmd_track)
41
+
42
+ return parser
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ argv = sys.argv[1:] if argv is None else argv
47
+ parser = _build_parser()
48
+
49
+ if not argv:
50
+ parser.print_help()
51
+ return 0
52
+
53
+ args = parser.parse_args(argv)
54
+ if hasattr(args, "func"):
55
+ return int(args.func(args) or 0)
56
+
57
+ parser.print_help()
58
+ return 2
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())
@@ -0,0 +1,465 @@
1
+ # src/disaster_factor/core.py
2
+
3
+ # IMPORTS
4
+ from __future__ import annotations
5
+ import csv
6
+ import logging
7
+ import math
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any, Optional, Tuple
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+ from .helpers import serve_static_and_open
14
+ from .geocode_assets import geocode_assets
15
+
16
+ LOG_FILE = Path(__file__).resolve().parents[2] / "disaster_factor.log"
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ def setup_logging(*, debug: bool = False) -> None:
21
+ level = logging.DEBUG if debug else logging.INFO
22
+
23
+ root = logging.getLogger()
24
+ root.setLevel(level)
25
+
26
+ # Prevent duplicate logs if setup_logging() is called more than once
27
+ root.handlers.clear()
28
+
29
+ fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
30
+
31
+ # Terminal
32
+ sh = logging.StreamHandler()
33
+ sh.setLevel(level)
34
+ sh.setFormatter(fmt)
35
+
36
+ # File
37
+ fh = logging.FileHandler(LOG_FILE, mode="w", encoding="utf-8")
38
+ fh.setLevel(level)
39
+ fh.setFormatter(fmt)
40
+
41
+ root.addHandler(sh)
42
+ root.addHandler(fh)
43
+
44
+
45
+ # ------------------------------------------------------------------------------------
46
+ # GDACS helpers
47
+ # ------------------------------------------------------------------------------------
48
+
49
+ def _find_text_suffix(tag, suffix: str) -> str:
50
+ """Find first sub-tag whose name ends with suffix (case-insensitive) and return stripped text."""
51
+
52
+ t = tag.find(lambda x: getattr(x, "name", None) and x.name.lower().endswith(suffix))
53
+ return (t.text or "").strip() if t and t.text else ""
54
+
55
+ def _extract_rss_geo_point(item) -> tuple[Optional[float], Optional[float], str]:
56
+ """
57
+ Extract numeric coordinates from RSS geo:Point.
58
+
59
+ Returns:
60
+ (lat, lon, reason)
61
+ reason: "ok" | "missing_tag" | "missing_latlon" | "non_numeric"
62
+ """
63
+ geo_point = item.find("geo:Point")
64
+ if not geo_point:
65
+ return None, None, "missing_tag"
66
+
67
+ lat_elem = geo_point.find("geo:lat")
68
+ lon_elem = geo_point.find("geo:long")
69
+
70
+ lat_text = (lat_elem.text or "").strip() if lat_elem else ""
71
+ lon_text = (lon_elem.text or "").strip() if lon_elem else ""
72
+
73
+ if not lat_text or not lon_text:
74
+ return None, None, "missing_latlon"
75
+
76
+ try:
77
+ return float(lat_text), float(lon_text), "ok"
78
+ except ValueError:
79
+ return None, None, "non_numeric"
80
+
81
+
82
+ def _build_rss_event_summary(item) -> Optional[dict[str, Any]]:
83
+ """
84
+ Build normalized RSS event summary.
85
+
86
+ Returns None when eventtype or eventid are missing.
87
+ Fields: eventid, eventtype, alertlevel, lat, lon, eventdata_url,
88
+ latitude, longitude (legacy string keys).
89
+ """
90
+ eventtype = _find_text_suffix(item, "eventtype")
91
+ eventid = _find_text_suffix(item, "eventid")
92
+ if not eventtype or not eventid:
93
+ return None
94
+
95
+ alertlevel = _find_text_suffix(item, "alertlevel")
96
+ lat, lon, _ = _extract_rss_geo_point(item)
97
+
98
+ return {
99
+ "eventid": eventid,
100
+ "eventtype": eventtype,
101
+ "alertlevel": alertlevel,
102
+ "lat": lat,
103
+ "lon": lon,
104
+ "eventdata_url": (
105
+ "https://www.gdacs.org/gdacsapi/api/events/geteventdata"
106
+ f"?eventtype={eventtype}&eventid={eventid}"
107
+ ),
108
+ "latitude": str(lat) if lat is not None else None,
109
+ "longitude": str(lon) if lon is not None else None,
110
+ }
111
+
112
+
113
+ _ALERT_PRIORITY: tuple[str, ...] = ("red", "orange", "green")
114
+
115
+
116
+ def _normalize_alertlevel(value: Any) -> Optional[str]:
117
+ """Normalize GDACS alert level to one of {red, orange, green}."""
118
+ normalized = str(value or "").strip().lower()
119
+ return normalized if normalized in _ALERT_PRIORITY else None
120
+
121
+
122
+ # ------------------------------------------------------------------------------------
123
+ # Euclidean impact decision helpers
124
+ # ------------------------------------------------------------------------------------
125
+
126
+ # Distance thresholds by disaster type (miles). Placeholder values — tune after refactor.
127
+ _THRESHOLD_MILES_BY_TYPE: dict[str, float] = {
128
+ "EQ": 150.0, # Earthquake
129
+ "TC": 200.0, # Tropical Cyclone
130
+ "FL": 75.0, # Flood
131
+ "VO": 100.0, # Volcano
132
+ # "DR": 150.0, # Drought
133
+ "WF": 75.0, # Wildfire
134
+ "TS": 250.0, # Tsunami
135
+ }
136
+ _THRESHOLD_MILES_DEFAULT = min(_THRESHOLD_MILES_BY_TYPE.values())
137
+
138
+
139
+ def _distance_threshold_miles(eventtype: str) -> float:
140
+ """Return distance threshold (miles) for a disaster type. Placeholder values."""
141
+ return _THRESHOLD_MILES_BY_TYPE.get(eventtype.strip().upper(), _THRESHOLD_MILES_DEFAULT)
142
+
143
+
144
+ _MILES_PER_DEGREE = 69.0 # approximate miles per degree of lat/lon
145
+
146
+ def _euclidean_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
147
+ """Straight Euclidean distance in miles between two lat/lon points. No curvature correction."""
148
+ dlat = (lat2 - lat1) * _MILES_PER_DEGREE
149
+ dlon = (lon2 - lon1) * _MILES_PER_DEGREE
150
+ return math.sqrt(dlat * dlat + dlon * dlon)
151
+
152
+
153
+ def _is_asset_affected(
154
+ asset_coord: tuple[float, float],
155
+ event: dict[str, Any],
156
+ ) -> bool:
157
+ """
158
+ Decide whether an asset is affected by an event.
159
+
160
+ Returns False for events without valid coordinates.
161
+ Uses straight Euclidean distance vs. disaster-type threshold.
162
+ """
163
+ lat = event.get("lat")
164
+ lon = event.get("lon")
165
+ if lat is None or lon is None:
166
+ return False
167
+
168
+ try:
169
+ event_lat = float(lat)
170
+ event_lon = float(lon)
171
+ except (TypeError, ValueError):
172
+ logger.debug(
173
+ "[INTEL] Skipping event with non-numeric coordinates: event_id=%s lat=%r lon=%r",
174
+ event.get("eventid", "unknown"),
175
+ lat,
176
+ lon,
177
+ )
178
+ return False
179
+
180
+ distance = _euclidean_distance(
181
+ asset_coord[0], asset_coord[1],
182
+ event_lat, event_lon,
183
+ )
184
+ return distance <= _distance_threshold_miles(event.get("eventtype", ""))
185
+
186
+
187
+
188
+ # ------------------------------------------------------------------------------------
189
+ # RAID pipeline
190
+ # ------------------------------------------------------------------------------------
191
+
192
+ def recon(debug: bool = False) -> tuple[int, list[dict[str, Any]]]:
193
+ """
194
+ R — RECON (DATA RETRIEVAL)
195
+
196
+ Fetch GDACS RSS feed and extract normalized event summaries with geo:Point coordinates.
197
+
198
+ Returns:
199
+ (total_red, events)
200
+
201
+ total_red: count of RSS items with alertlevel == "Red"
202
+ events: list[dict] with keys {eventid, eventtype, alertlevel, lat, lon}
203
+ """
204
+
205
+ rss_url = "https://www.gdacs.org/XML/RSS.xml"
206
+
207
+ resp = requests.get(rss_url, timeout=20)
208
+ resp.raise_for_status()
209
+
210
+ soup = BeautifulSoup(resp.content, features="xml")
211
+ items = soup.find_all("item")
212
+
213
+ # geo:Point audit
214
+ geo_point_total = 0
215
+ geo_point_missing_tag = 0
216
+ geo_point_missing_latlon = 0
217
+ geo_point_non_numeric = 0
218
+ geo_point_valid = 0
219
+
220
+ events: list[dict[str, Any]] = []
221
+ total_red = 0
222
+ total_orange = 0
223
+ total_green = 0
224
+
225
+ for item in items:
226
+ geo_point_total += 1
227
+
228
+ lat, lon, geo_reason = _extract_rss_geo_point(item)
229
+ if geo_reason == "missing_tag":
230
+ geo_point_missing_tag += 1
231
+ elif geo_reason == "missing_latlon":
232
+ geo_point_missing_latlon += 1
233
+ elif geo_reason == "non_numeric":
234
+ geo_point_non_numeric += 1
235
+ elif geo_reason == "ok":
236
+ geo_point_valid += 1
237
+
238
+ alert = _normalize_alertlevel(_find_text_suffix(item, "alertlevel"))
239
+ if alert == "red":
240
+ total_red += 1
241
+ elif alert == "orange":
242
+ total_orange += 1
243
+ elif alert == "green":
244
+ total_green += 1
245
+
246
+ event = _build_rss_event_summary(item)
247
+ if not event:
248
+ continue
249
+
250
+ events.append(event)
251
+
252
+ for event in events:
253
+ if _normalize_alertlevel(event.get("alertlevel")) == "red":
254
+ logger.info(
255
+ "[RECON] RED ALERT: %s %s lat=%s lon=%s",
256
+ event["eventtype"], event["eventid"],
257
+ event["lat"], event["lon"],
258
+ )
259
+
260
+ # Dev cap (optional)
261
+ cap_raw = os.getenv("GDACS_DEV_CAP", "").strip()
262
+ if cap_raw.isdigit() and int(cap_raw) > 0:
263
+ events = events[:int(cap_raw)]
264
+
265
+ if debug:
266
+ logger.debug("[RECON] RSS Collection Summary:")
267
+ logger.debug(f" RSS items: {geo_point_total}")
268
+ logger.debug(f" Events extracted: {len(events)}")
269
+ logger.debug(f" Red alert events: {total_red}")
270
+ logger.debug(f" Orange alert events: {total_orange}")
271
+ logger.debug(f" Green alert events: {total_green}")
272
+ logger.debug(f" Valid geo:Point coordinates: {geo_point_valid}")
273
+ logger.debug(f" Missing geo:Point tag: {geo_point_missing_tag}")
274
+ logger.debug(f" Missing geo:lat/geo:long: {geo_point_missing_latlon}")
275
+ logger.debug(f" Non-numeric geo:lat/geo:long: {geo_point_non_numeric}")
276
+
277
+ return total_red, events
278
+
279
+
280
+ def assets() -> tuple[dict[str, str], dict[str, str], dict[str, Optional[Tuple[float, float]]], dict[str, dict[str, str]]]:
281
+ """
282
+ A — ASSETS (DATA INPUT)
283
+ Load company / contractor asset data (cities, countries, assets, etc.) from geocode_assets() 'assets' object.
284
+ The assets object is expected to have at least the columns:
285
+ - unique_id (anonymized unique ID, no PII)
286
+ - city
287
+ - country
288
+ - type (e.g. 'personnel', 'building', 'vehicle', ...)
289
+ - latitude (pre-geocoded coordinates)
290
+ - longitude (pre-geocoded coordinates)
291
+ Returns:
292
+ cities: mapping[str, str] optional lookup of asset_id -> city name
293
+ countries: mapping[str, str] optional lookup of asset_id -> country name
294
+ coordinates: mapping[str, Tuple[float, float]] asset_id -> (latitude, longitude)
295
+ assets_by_id: mapping[str, dict[str, str]] core asset records used for
296
+ impact matching. Each asset dict should at least contain
297
+ ``city``, ``country``, and ``type``.
298
+ """
299
+ cities: dict[str, str] = {}
300
+ countries: dict[str, str] = {}
301
+ coordinates: dict[str, Optional[Tuple[float, float]]] = {}
302
+ assets_by_id: dict[str, dict[str, str]] = {}
303
+
304
+ logger.info("[ASSETS] Loading assets with pre-geocoded coordinates...")
305
+ assets = geocode_assets()
306
+ loaded_count = 0
307
+ coord_count = 0
308
+
309
+ for asset_row in assets:
310
+ asset_id = (asset_row.get("unique_id") or "").strip()
311
+ if not asset_id:
312
+ continue
313
+ loaded_count += 1
314
+
315
+ lat = asset_row.get("latitude")
316
+ lon = asset_row.get("longitude")
317
+
318
+ if lat not in (None, "") and lon not in (None, ""):
319
+ coordinates[asset_id] = (lat, lon)
320
+ coord_count += 1
321
+ else:
322
+ coordinates[asset_id] = None
323
+
324
+ assets_by_id[asset_id] = asset_row
325
+ cities[asset_id] = (asset_row.get("city") or "").strip()
326
+ countries[asset_id] = (asset_row.get("country") or "").strip()
327
+ logger.info(f"[ASSETS] Loaded {loaded_count} assets with {coord_count} having valid coordinates")
328
+
329
+ return cities, countries, coordinates, assets_by_id
330
+
331
+ def intel(
332
+ events: list[dict[str, Any]],
333
+ coordinates: dict[str, Optional[Tuple[float, float]]],
334
+ cities: dict[str, str],
335
+ countries: dict[str, str],
336
+ assets_by_id: dict[str, dict[str, str]],
337
+ ) -> tuple[list[dict[str, str]], list[dict[str, str]], list[dict[str, Any]]]:
338
+ """
339
+ I — INTEL (Euclidean path)
340
+
341
+ One-pass: for each asset with valid coordinates, check events in priority
342
+ order red -> orange -> green, and stop on the first matched tier using
343
+ straight Euclidean distance and disaster-type thresholds.
344
+
345
+ Returns:
346
+ (red_matches, prelim_matches, red_points)
347
+ """
348
+ red_matches: list[dict[str, str]] = []
349
+ prelim_matches: list[dict[str, str]] = []
350
+ red_points: list[dict[str, Any]] = []
351
+
352
+ events_by_severity: dict[str, list[dict[str, Any]]] = {sev: [] for sev in _ALERT_PRIORITY}
353
+ for event in events:
354
+ severity = _normalize_alertlevel(event.get("alertlevel"))
355
+ if severity is None:
356
+ continue
357
+ events_by_severity[severity].append(event)
358
+
359
+ for asset_id in assets_by_id:
360
+ asset_coords = coordinates.get(asset_id)
361
+ if not (
362
+ isinstance(asset_coords, (tuple, list))
363
+ and len(asset_coords) == 2
364
+ and isinstance(asset_coords[0], (int, float))
365
+ and isinstance(asset_coords[1], (int, float))
366
+ ):
367
+ continue
368
+
369
+ matched_event: Optional[dict[str, Any]] = None
370
+ matched_severity: Optional[str] = None
371
+ for severity in _ALERT_PRIORITY:
372
+ for event in events_by_severity[severity]:
373
+ if _is_asset_affected(asset_coords, event):
374
+ matched_event = event
375
+ matched_severity = severity
376
+ break
377
+ if matched_event is not None:
378
+ break
379
+
380
+ if matched_event is None or matched_severity is None:
381
+ continue
382
+
383
+ base_match = {
384
+ "unique_id": asset_id,
385
+ "city": cities.get(asset_id, ""),
386
+ "country": countries.get(asset_id, ""),
387
+ "event_type": matched_event.get("eventtype", "unknown"),
388
+ "event_id": matched_event.get("eventid", "unknown"),
389
+ "impact_method": "EUCLIDEAN",
390
+ "coordinates": f"{asset_coords[0]:.4f}, {asset_coords[1]:.4f}",
391
+ }
392
+ prelim_matches.append({
393
+ **base_match,
394
+ "severity": matched_severity,
395
+ })
396
+ if matched_severity == "red":
397
+ red_matches.append(base_match)
398
+ label = ", ".join(
399
+ p for p in (cities.get(asset_id, "").strip(), countries.get(asset_id, "").strip()) if p
400
+ ) or asset_id
401
+ red_points.append({
402
+ "lat": float(asset_coords[0]),
403
+ "lon": float(asset_coords[1]),
404
+ "label": label,
405
+ "severity": "red",
406
+ })
407
+
408
+ return red_matches, prelim_matches, red_points
409
+
410
+
411
+ def disseminate(
412
+ red_matches: list[dict[str, str]],
413
+ prelim_matches: list[dict[str, str]],
414
+ red_points: list[dict[str, Any]],
415
+ total_red: int,
416
+ debug: bool = False,
417
+ ) -> tuple[list[dict], int]:
418
+ """
419
+ D — DISSEMINATE (OUTPUT)
420
+
421
+ - Capture output in Python object.
422
+ - Launch the static dashboard UI (disabled in debug mode).
423
+ """
424
+
425
+ logger.info(
426
+ "[DISSEMINATE] affected=%d rows, prelim=%d rows, points=%d (total_red=%d)",
427
+ len(red_matches),
428
+ len(prelim_matches),
429
+ len(red_points),
430
+ total_red,
431
+ )
432
+
433
+ return red_matches, prelim_matches, red_points, total_red
434
+
435
+ def track_disasters(debug: bool = False) -> None:
436
+ """
437
+ Orchestrator for the full disaster tracking pipeline.
438
+
439
+ RAID-style flow:
440
+ R — recon() : collect RSS events with geo coordinates
441
+ A — assets() : load company assets with coordinates
442
+ I — intel() : priority classification (red/orange/green)
443
+ D — disseminate() : store output in memory + launch dashboard
444
+ """
445
+
446
+ setup_logging(debug=debug)
447
+ logger.info("=" * 80)
448
+ logger.info("DISASTER FACTOR - EUCLIDEAN IMPACT ANALYSIS")
449
+ logger.info("=" * 80)
450
+
451
+ # Load enhanced assets with coordinates
452
+ cities, countries, coordinates, assets_by_id = assets()
453
+
454
+ # Collect disaster intel from RSS
455
+ total_red, events = recon(debug)
456
+
457
+ # Euclidean impact assessment with severity priority
458
+ red_matches, prelim_matches, red_points = intel(events, coordinates, cities, countries, assets_by_id)
459
+
460
+ # Output results
461
+ final_output = disseminate(red_matches, prelim_matches, red_points, total_red, debug)
462
+
463
+ # Serve dashboard static
464
+ if not debug:
465
+ serve_static_and_open()