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.
- disaster_factor/__init__.py +10 -0
- disaster_factor/__main__.py +7 -0
- disaster_factor/cli.py +62 -0
- disaster_factor/core.py +465 -0
- disaster_factor/geocode_assets.py +169 -0
- disaster_factor/helpers.py +351 -0
- disaster_factor/static/assets.csv +49 -0
- disaster_factor/static/dashboard_2.html +68 -0
- disaster_factor/static/map.svg +2051 -0
- disaster_factor/static/map_config.json +5 -0
- disaster_factor/static/mystyle.css +199 -0
- disaster_factor/static/points.json +14 -0
- disaster_factor/static/script.js +74 -0
- disaster_factor/tools/calibrate.py +155 -0
- disaster_factor-0.1.0.dist-info/METADATA +84 -0
- disaster_factor-0.1.0.dist-info/RECORD +19 -0
- disaster_factor-0.1.0.dist-info/WHEEL +4 -0
- disaster_factor-0.1.0.dist-info/entry_points.txt +3 -0
- disaster_factor-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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
|
+
|
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())
|
disaster_factor/core.py
ADDED
|
@@ -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()
|