sibi-dst 2025.1.12__py3-none-any.whl → 2025.8.1__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.
- sibi_dst/__init__.py +7 -1
- sibi_dst/df_helper/_artifact_updater_multi_wrapper.py +235 -342
- sibi_dst/df_helper/_df_helper.py +417 -117
- sibi_dst/df_helper/_parquet_artifact.py +255 -283
- sibi_dst/df_helper/backends/parquet/_parquet_options.py +8 -4
- sibi_dst/df_helper/backends/sqlalchemy/_db_connection.py +68 -107
- sibi_dst/df_helper/backends/sqlalchemy/_db_gatekeeper.py +15 -0
- sibi_dst/df_helper/backends/sqlalchemy/_io_dask.py +105 -255
- sibi_dst/df_helper/backends/sqlalchemy/_load_from_db.py +90 -42
- sibi_dst/df_helper/backends/sqlalchemy/_model_registry.py +192 -0
- sibi_dst/df_helper/backends/sqlalchemy/_sql_model_builder.py +122 -72
- sibi_dst/osmnx_helper/__init__.py +1 -0
- sibi_dst/osmnx_helper/basemaps/route_map_plotter.py +203 -0
- sibi_dst/osmnx_helper/route_path_builder.py +97 -0
- sibi_dst/osmnx_helper/utils.py +2 -0
- sibi_dst/utils/base.py +302 -96
- sibi_dst/utils/clickhouse_writer.py +472 -206
- sibi_dst/utils/data_utils.py +139 -186
- sibi_dst/utils/data_wrapper.py +317 -73
- sibi_dst/utils/date_utils.py +1 -0
- sibi_dst/utils/df_utils.py +193 -213
- sibi_dst/utils/file_utils.py +3 -2
- sibi_dst/utils/filepath_generator.py +314 -152
- sibi_dst/utils/log_utils.py +581 -242
- sibi_dst/utils/manifest_manager.py +60 -76
- sibi_dst/utils/parquet_saver.py +33 -27
- sibi_dst/utils/phone_formatter.py +88 -95
- sibi_dst/utils/update_planner.py +180 -178
- sibi_dst/utils/webdav_client.py +116 -166
- {sibi_dst-2025.1.12.dist-info → sibi_dst-2025.8.1.dist-info}/METADATA +1 -1
- {sibi_dst-2025.1.12.dist-info → sibi_dst-2025.8.1.dist-info}/RECORD +32 -28
- {sibi_dst-2025.1.12.dist-info → sibi_dst-2025.8.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,192 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
import threading
|
5
|
+
from typing import Dict, Optional, Tuple
|
6
|
+
|
7
|
+
from sqlalchemy import MetaData, Table
|
8
|
+
from sqlalchemy.engine import Engine
|
9
|
+
from sqlalchemy.orm import DeclarativeBase
|
10
|
+
|
11
|
+
|
12
|
+
class Base(DeclarativeBase):
|
13
|
+
"""Shared declarative base for all ORM models."""
|
14
|
+
pass
|
15
|
+
|
16
|
+
|
17
|
+
# Backward-compatible default module label for generated classes
|
18
|
+
apps_label = "datacubes.models"
|
19
|
+
|
20
|
+
|
21
|
+
class ModelRegistry:
|
22
|
+
"""
|
23
|
+
Thread-safe registry that reflects tables once per (engine, schema) and
|
24
|
+
returns a single mapped class per (engine, schema, table).
|
25
|
+
"""
|
26
|
+
|
27
|
+
def __init__(self) -> None:
|
28
|
+
self._metadata_cache: Dict[Tuple[str, Optional[str]], MetaData] = {}
|
29
|
+
self._model_cache: Dict[Tuple[str, Optional[str], str], type] = {}
|
30
|
+
self._lock = threading.RLock()
|
31
|
+
self._md_locks: Dict[Tuple[str, Optional[str]], threading.Lock] = {}
|
32
|
+
|
33
|
+
# ---------- key helpers ----------
|
34
|
+
@staticmethod
|
35
|
+
def _engine_key(engine: Engine) -> str:
|
36
|
+
return str(engine.url)
|
37
|
+
|
38
|
+
@staticmethod
|
39
|
+
def _qualified_key(schema: Optional[str], table: str) -> str:
|
40
|
+
return f"{schema}.{table}" if schema else table
|
41
|
+
|
42
|
+
@staticmethod
|
43
|
+
def _split_schema_and_table(name: str) -> Tuple[Optional[str], str]:
|
44
|
+
if "." in name:
|
45
|
+
s, t = name.split(".", 1)
|
46
|
+
return (s or None), t
|
47
|
+
return None, name
|
48
|
+
|
49
|
+
# ---------- class name helpers ----------
|
50
|
+
@staticmethod
|
51
|
+
def _normalize_class_name(table_name: str) -> str:
|
52
|
+
return "".join(part.capitalize() for part in table_name.split("_"))
|
53
|
+
|
54
|
+
@staticmethod
|
55
|
+
def _short_hash(*parts: str, length: int = 8) -> str:
|
56
|
+
h = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()
|
57
|
+
return h[:length]
|
58
|
+
|
59
|
+
def _is_class_name_taken(self, class_name: str, module_label: str) -> bool:
|
60
|
+
# Avoid SA private registries; inspect mappers instead (public)
|
61
|
+
for mapper in list(Base.registry.mappers):
|
62
|
+
try:
|
63
|
+
cls = mapper.class_
|
64
|
+
if getattr(cls, "__name__", None) == class_name and getattr(cls, "__module__", None) == module_label:
|
65
|
+
return True
|
66
|
+
except Exception:
|
67
|
+
continue
|
68
|
+
return False
|
69
|
+
|
70
|
+
def _find_existing_model_for_table(self, tbl: Table) -> Optional[type]:
|
71
|
+
for mapper in list(Base.registry.mappers):
|
72
|
+
try:
|
73
|
+
mapped_cls = mapper.class_
|
74
|
+
mapped_tbl = getattr(mapped_cls, "__table__", None)
|
75
|
+
if mapped_tbl is tbl:
|
76
|
+
return mapped_cls
|
77
|
+
if isinstance(mapped_tbl, Table):
|
78
|
+
if (mapped_tbl.schema == tbl.schema) and (mapped_tbl.name == tbl.name):
|
79
|
+
return mapped_cls
|
80
|
+
except Exception:
|
81
|
+
continue
|
82
|
+
return None
|
83
|
+
|
84
|
+
# ---------- metadata helpers ----------
|
85
|
+
def _get_or_create_metadata(self, ekey: str, schema: Optional[str]) -> MetaData:
|
86
|
+
md_key = (ekey, schema)
|
87
|
+
with self._lock:
|
88
|
+
md = self._metadata_cache.get(md_key)
|
89
|
+
if md is None:
|
90
|
+
md = MetaData(schema=schema)
|
91
|
+
self._metadata_cache[md_key] = md
|
92
|
+
return md
|
93
|
+
|
94
|
+
def _get_or_create_md_lock(self, md_key: Tuple[str, Optional[str]]) -> threading.Lock:
|
95
|
+
with self._lock:
|
96
|
+
lock = self._md_locks.get(md_key)
|
97
|
+
if lock is None:
|
98
|
+
lock = threading.Lock()
|
99
|
+
self._md_locks[md_key] = lock
|
100
|
+
return lock
|
101
|
+
|
102
|
+
# ---------- public API ----------
|
103
|
+
def get_model(
|
104
|
+
self,
|
105
|
+
engine: Engine,
|
106
|
+
table_name: str,
|
107
|
+
*,
|
108
|
+
refresh: bool = False,
|
109
|
+
schema: Optional[str] = None,
|
110
|
+
module_label: Optional[str] = None,
|
111
|
+
prefer_stable_names: bool = True,
|
112
|
+
) -> type:
|
113
|
+
s2, tname = self._split_schema_and_table(table_name)
|
114
|
+
schema = schema if schema is not None else s2
|
115
|
+
ekey = self._engine_key(engine)
|
116
|
+
model_key = (ekey, schema, tname)
|
117
|
+
md_key = (ekey, schema)
|
118
|
+
module_label = module_label or apps_label
|
119
|
+
|
120
|
+
if refresh:
|
121
|
+
with self._lock:
|
122
|
+
self._model_cache.pop(model_key, None)
|
123
|
+
self._metadata_cache.pop(md_key, None)
|
124
|
+
self._md_locks.pop(md_key, None)
|
125
|
+
|
126
|
+
# fast path: already cached model
|
127
|
+
with self._lock:
|
128
|
+
m = self._model_cache.get(model_key)
|
129
|
+
if m is not None:
|
130
|
+
return m
|
131
|
+
|
132
|
+
# ensure metadata and reflection are serialized per (engine, schema)
|
133
|
+
md = self._get_or_create_metadata(ekey, schema)
|
134
|
+
md_lock = self._get_or_create_md_lock(md_key)
|
135
|
+
qname = self._qualified_key(schema, tname)
|
136
|
+
|
137
|
+
tbl = md.tables.get(qname)
|
138
|
+
if tbl is None:
|
139
|
+
with md_lock:
|
140
|
+
# double-checked reflection
|
141
|
+
tbl = md.tables.get(qname)
|
142
|
+
if tbl is None:
|
143
|
+
md.reflect(bind=engine, only=[qname])
|
144
|
+
tbl = md.tables.get(qname)
|
145
|
+
|
146
|
+
if tbl is None:
|
147
|
+
raise ValueError(f"Table '{qname}' does not exist in the database.")
|
148
|
+
|
149
|
+
# If a mapped model for this Table already exists (anywhere), reuse it
|
150
|
+
reused = self._find_existing_model_for_table(tbl)
|
151
|
+
if reused is not None:
|
152
|
+
with self._lock:
|
153
|
+
self._model_cache[model_key] = reused
|
154
|
+
return reused
|
155
|
+
|
156
|
+
# pick class name
|
157
|
+
base_name = self._normalize_class_name(tname)
|
158
|
+
final_name = base_name
|
159
|
+
if self._is_class_name_taken(base_name, module_label):
|
160
|
+
# optionally keep stable names by suffixing with a short hash
|
161
|
+
if prefer_stable_names:
|
162
|
+
suffix = self._short_hash(ekey, schema or "", tname)
|
163
|
+
final_name = f"{base_name}_{suffix}"
|
164
|
+
else:
|
165
|
+
# let SQLAlchemy registry replacement occur (not recommended)
|
166
|
+
suffix = self._short_hash(ekey, schema or "", tname)
|
167
|
+
final_name = f"{base_name}_{suffix}"
|
168
|
+
|
169
|
+
# build the model
|
170
|
+
attrs = {
|
171
|
+
"__tablename__": tbl.name,
|
172
|
+
"__table__": tbl,
|
173
|
+
"__module__": module_label,
|
174
|
+
}
|
175
|
+
model_cls = type(final_name, (Base,), attrs)
|
176
|
+
|
177
|
+
with self._lock:
|
178
|
+
self._model_cache[model_key] = model_cls
|
179
|
+
return model_cls
|
180
|
+
|
181
|
+
def clear(self) -> None:
|
182
|
+
with self._lock:
|
183
|
+
self._metadata_cache.clear()
|
184
|
+
self._model_cache.clear()
|
185
|
+
self._md_locks.clear()
|
186
|
+
|
187
|
+
|
188
|
+
# Process-wide registry & helper
|
189
|
+
_global_registry = ModelRegistry()
|
190
|
+
|
191
|
+
def get_global_registry() -> ModelRegistry:
|
192
|
+
return _global_registry
|
@@ -1,104 +1,154 @@
|
|
1
|
-
import re
|
2
1
|
import keyword
|
2
|
+
import re
|
3
3
|
import threading
|
4
|
-
from sqlalchemy import
|
5
|
-
from sqlalchemy.orm import DeclarativeBase
|
6
|
-
|
4
|
+
from sqlalchemy.engine import Engine
|
7
5
|
|
8
|
-
|
9
|
-
"""Shared declarative base for all ORM models."""
|
10
|
-
pass
|
6
|
+
from ._model_registry import ModelRegistry, apps_label
|
11
7
|
|
12
8
|
|
13
|
-
|
9
|
+
# Global process-wide registry for backward compatibility
|
10
|
+
_global_model_registry = ModelRegistry()
|
14
11
|
|
15
12
|
|
16
13
|
class SqlAlchemyModelBuilder:
|
17
14
|
"""
|
18
15
|
Builds a single SQLAlchemy ORM model from a specific database table.
|
19
|
-
|
20
|
-
|
16
|
+
Thread-safe and uses a process-wide registry for reuse.
|
17
|
+
|
18
|
+
Backward compatibility:
|
19
|
+
- Keeps CamelCase(table) as preferred class name
|
20
|
+
- Publishes classes under `apps_label` unless overridden
|
21
|
+
- Public API unchanged
|
21
22
|
"""
|
23
|
+
|
22
24
|
_lock = threading.Lock()
|
23
|
-
_metadata_cache: dict[str, MetaData] = {}
|
24
25
|
|
25
26
|
def __init__(self, engine: Engine, table_name: str):
|
26
|
-
"""
|
27
|
-
Initializes the model builder for a specific table.
|
28
|
-
|
29
|
-
Args:
|
30
|
-
engine: The SQLAlchemy engine connected to the database.
|
31
|
-
table_name: The name of the table to generate the model for.
|
32
|
-
"""
|
33
27
|
self.engine = engine
|
34
28
|
self.table_name = table_name
|
35
|
-
self.class_name = self._normalize_class_name(self.table_name)
|
36
|
-
|
37
|
-
engine_key = str(engine.url)
|
38
|
-
|
39
|
-
# ✅ REFACTOR: Acquire lock to make cache access and creation atomic,
|
40
|
-
# preventing a race condition between multiple threads.
|
41
|
-
with self._lock:
|
42
|
-
if engine_key not in self._metadata_cache:
|
43
|
-
self._metadata_cache[engine_key] = MetaData()
|
44
|
-
self.metadata = self._metadata_cache[engine_key]
|
45
29
|
|
46
30
|
def build_model(self) -> type:
|
47
|
-
"""
|
48
|
-
Builds and returns a database model class for the specified table.
|
49
|
-
This process is atomic and thread-safe.
|
50
|
-
|
51
|
-
Raises:
|
52
|
-
ValueError: If the specified table does not exist in the database.
|
53
|
-
Returns:
|
54
|
-
The dynamically created ORM model class.
|
55
|
-
"""
|
56
31
|
with self._lock:
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
# Check if the table's schema is in our metadata cache
|
64
|
-
table = self.metadata.tables.get(self.table_name)
|
65
|
-
|
66
|
-
# If not cached, reflect it from the database
|
67
|
-
if table is None:
|
68
|
-
self.metadata.reflect(bind=self.engine, only=[self.table_name])
|
69
|
-
table = self.metadata.tables.get(self.table_name)
|
70
|
-
|
71
|
-
if table is None:
|
72
|
-
raise ValueError(
|
73
|
-
f"Table '{self.table_name}' does not exist in the database."
|
74
|
-
)
|
75
|
-
|
76
|
-
# Create the model class dynamically.
|
77
|
-
attrs = {
|
78
|
-
"__tablename__": table.name,
|
79
|
-
"__table__": table,
|
80
|
-
"__module__": apps_label,
|
81
|
-
}
|
82
|
-
model = type(self.class_name, (Base,), attrs)
|
83
|
-
|
84
|
-
return model
|
32
|
+
return _global_model_registry.get_model(
|
33
|
+
engine=self.engine,
|
34
|
+
table_name=self.table_name,
|
35
|
+
module_label=apps_label,
|
36
|
+
prefer_stable_names=True,
|
37
|
+
)
|
85
38
|
|
86
39
|
@staticmethod
|
87
40
|
def _normalize_class_name(table_name: str) -> str:
|
88
|
-
"""Converts a snake_case table_name to a CamelCase class name."""
|
89
41
|
return "".join(word.capitalize() for word in table_name.split("_"))
|
90
42
|
|
91
43
|
@staticmethod
|
92
44
|
def _normalize_column_name(column_name: str) -> str:
|
93
|
-
"""
|
94
|
-
Sanitizes a column name to be a valid Python identifier.
|
95
|
-
(Kept for utility, though not used in the final model creation).
|
96
|
-
"""
|
97
45
|
sane_name = re.sub(r"\W", "_", column_name)
|
98
46
|
sane_name = re.sub(r"^\d", r"_\g<0>", sane_name)
|
99
|
-
|
100
47
|
if keyword.iskeyword(sane_name):
|
101
48
|
return f"{sane_name}_field"
|
102
49
|
return sane_name
|
103
50
|
|
104
|
-
|
51
|
+
# import re
|
52
|
+
# import keyword
|
53
|
+
# import threading
|
54
|
+
# from sqlalchemy import MetaData, Engine
|
55
|
+
# from sqlalchemy.orm import DeclarativeBase
|
56
|
+
#
|
57
|
+
#
|
58
|
+
# class Base(DeclarativeBase):
|
59
|
+
# """Shared declarative base for all ORM models."""
|
60
|
+
# pass
|
61
|
+
#
|
62
|
+
#
|
63
|
+
# apps_label = "datacubes.models"
|
64
|
+
#
|
65
|
+
#
|
66
|
+
# class SqlAlchemyModelBuilder:
|
67
|
+
# """
|
68
|
+
# Builds a single SQLAlchemy ORM model from a specific database table.
|
69
|
+
# This class is thread-safe and caches reflected table metadata to
|
70
|
+
# improve performance across multiple instantiations.
|
71
|
+
# """
|
72
|
+
# _lock = threading.Lock()
|
73
|
+
# _metadata_cache: dict[str, MetaData] = {}
|
74
|
+
#
|
75
|
+
# def __init__(self, engine: Engine, table_name: str):
|
76
|
+
# """
|
77
|
+
# Initializes the model builder for a specific table.
|
78
|
+
#
|
79
|
+
# Args:
|
80
|
+
# engine: The SQLAlchemy engine connected to the database.
|
81
|
+
# table_name: The name of the table to generate the model for.
|
82
|
+
# """
|
83
|
+
# self.engine = engine
|
84
|
+
# self.table_name = table_name
|
85
|
+
# self.class_name = self._normalize_class_name(self.table_name)
|
86
|
+
#
|
87
|
+
# engine_key = str(engine.url)
|
88
|
+
#
|
89
|
+
# # ✅ REFACTOR: Acquire lock to make cache access and creation atomic,
|
90
|
+
# # preventing a race condition between multiple threads.
|
91
|
+
# with self._lock:
|
92
|
+
# if engine_key not in self._metadata_cache:
|
93
|
+
# self._metadata_cache[engine_key] = MetaData()
|
94
|
+
# self.metadata = self._metadata_cache[engine_key]
|
95
|
+
#
|
96
|
+
# def build_model(self) -> type:
|
97
|
+
# """
|
98
|
+
# Builds and returns a database model class for the specified table.
|
99
|
+
# This process is atomic and thread-safe.
|
100
|
+
#
|
101
|
+
# Raises:
|
102
|
+
# ValueError: If the specified table does not exist in the database.
|
103
|
+
# Returns:
|
104
|
+
# The dynamically created ORM model class.
|
105
|
+
# """
|
106
|
+
# with self._lock:
|
107
|
+
# # NOTE: Using a private SQLAlchemy API. This is a performance
|
108
|
+
# # optimization but may break in future versions of the library.
|
109
|
+
# registered_model = Base.registry._class_registry.get(self.class_name)
|
110
|
+
# if registered_model:
|
111
|
+
# return registered_model
|
112
|
+
#
|
113
|
+
# # Check if the table's schema is in our metadata cache
|
114
|
+
# table = self.metadata.tables.get(self.table_name)
|
115
|
+
#
|
116
|
+
# # If not cached, reflect it from the database
|
117
|
+
# if table is None:
|
118
|
+
# self.metadata.reflect(bind=self.engine, only=[self.table_name])
|
119
|
+
# table = self.metadata.tables.get(self.table_name)
|
120
|
+
#
|
121
|
+
# if table is None:
|
122
|
+
# raise ValueError(
|
123
|
+
# f"Table '{self.table_name}' does not exist in the database."
|
124
|
+
# )
|
125
|
+
#
|
126
|
+
# # Create the model class dynamically.
|
127
|
+
# attrs = {
|
128
|
+
# "__tablename__": table.name,
|
129
|
+
# "__table__": table,
|
130
|
+
# "__module__": apps_label,
|
131
|
+
# }
|
132
|
+
# model = type(self.class_name, (Base,), attrs)
|
133
|
+
#
|
134
|
+
# return model
|
135
|
+
#
|
136
|
+
# @staticmethod
|
137
|
+
# def _normalize_class_name(table_name: str) -> str:
|
138
|
+
# """Converts a snake_case table_name to a CamelCase class name."""
|
139
|
+
# return "".join(word.capitalize() for word in table_name.split("_"))
|
140
|
+
#
|
141
|
+
# @staticmethod
|
142
|
+
# def _normalize_column_name(column_name: str) -> str:
|
143
|
+
# """
|
144
|
+
# Sanitizes a column name to be a valid Python identifier.
|
145
|
+
# (Kept for utility, though not used in the final model creation).
|
146
|
+
# """
|
147
|
+
# sane_name = re.sub(r"\W", "_", column_name)
|
148
|
+
# sane_name = re.sub(r"^\d", r"_\g<0>", sane_name)
|
149
|
+
#
|
150
|
+
# if keyword.iskeyword(sane_name):
|
151
|
+
# return f"{sane_name}_field"
|
152
|
+
# return sane_name
|
153
|
+
#
|
154
|
+
#
|
@@ -0,0 +1,203 @@
|
|
1
|
+
import folium
|
2
|
+
import math
|
3
|
+
import networkx as nx
|
4
|
+
import pandas as pd
|
5
|
+
from datetime import datetime
|
6
|
+
from folium.plugins import AntPath, PolyLineTextPath
|
7
|
+
from geopy.distance import geodesic
|
8
|
+
from pydantic import BaseModel, Field
|
9
|
+
from typing import Optional, Dict, List, Tuple
|
10
|
+
|
11
|
+
def get_default_action_colors() -> Dict[str, str]:
|
12
|
+
"""Provides a default dictionary for action colors."""
|
13
|
+
return {
|
14
|
+
"Reporte Posición": "blue", "Acceso o Salida": "lightblue",
|
15
|
+
"Producto para Reintentar": "orange", "Formalización iniciada": "purple",
|
16
|
+
"Producto Formalizado": "green", "Esfuerzo Registrado": "darkblue",
|
17
|
+
"Abandona Formalización": "lightred", "Producto Rechazado": "red",
|
18
|
+
"Producto Devuelto": "darkred",
|
19
|
+
}
|
20
|
+
|
21
|
+
class RouteMapPlotterSettings(BaseModel):
|
22
|
+
"""Manages all configuration settings for the RouteMapPlotter."""
|
23
|
+
use_antpath: bool = Field(True, description="If True, use animated AntPath; otherwise, use a static PolyLine.")
|
24
|
+
zoom_start: int = Field(14, description="Initial zoom level for the map.")
|
25
|
+
forward_color: str = Field("blue", description="Color for the outbound portion of the route.")
|
26
|
+
return_color: str = Field("red", description="Color for the return portion of the route.")
|
27
|
+
return_offset_m: float = Field(2.5, description="Meters to offset the return path for visibility.")
|
28
|
+
antpath_delay: int = Field(800, description="Delay in ms for the AntPath animation.")
|
29
|
+
antpath_weight: int = Field(5, description="Weight of the AntPath line.")
|
30
|
+
antpath_dash_array: List[int] = Field(default=[10, 20], description="Dash pattern for the AntPath.")
|
31
|
+
antpath_pulse_color: str = Field("white", description="Pulse color for the AntPath animation.")
|
32
|
+
marker_origin_color: str = Field("green", description="Color for the start marker.")
|
33
|
+
marker_end_color: str = Field("red", description="Color for the finish marker.")
|
34
|
+
furthest_marker_color: str = Field("orange", description="Color for the furthest point marker.")
|
35
|
+
arrow_color: str = Field("black", description="Color for direction arrows on static PolyLines.")
|
36
|
+
marker_radius: int = Field(6, description="Radius of the event circle markers.")
|
37
|
+
default_tile: str = Field("OpenStreetMap", description="The default map tile layer to display.")
|
38
|
+
arrow_spacing: int = Field(75, description="Spacing in pixels between arrows on static PolyLines.")
|
39
|
+
action_colors: Dict[str, str] = Field(default_factory=get_default_action_colors)
|
40
|
+
date_field: str = Field("date_time", description="Name of the date/time field in the DataFrame.")
|
41
|
+
lat_col: str = Field("latitude", description="Name of the latitude field in the DataFrame.")
|
42
|
+
lon_col: str = Field("longitude", description="Name of the longitude field in the DataFrame.")
|
43
|
+
|
44
|
+
class RouteDataPoint(BaseModel):
|
45
|
+
"""Defines the data schema for a single row in the input DataFrame."""
|
46
|
+
latitude: float
|
47
|
+
longitude: float
|
48
|
+
date_time: datetime
|
49
|
+
origin_node: int
|
50
|
+
dest_node: int
|
51
|
+
path_nodes: List[int]
|
52
|
+
action: Optional[str] = None
|
53
|
+
|
54
|
+
class RouteMapPlotter:
|
55
|
+
def __init__(
|
56
|
+
self,
|
57
|
+
graph: nx.Graph,
|
58
|
+
settings: Optional[RouteMapPlotterSettings] = None,
|
59
|
+
):
|
60
|
+
if not isinstance(graph, nx.Graph) or not graph.nodes:
|
61
|
+
raise ValueError("A valid NetworkX graph with nodes is required.")
|
62
|
+
self.graph = graph
|
63
|
+
self.settings = settings or RouteMapPlotterSettings()
|
64
|
+
self.tile_layers = {
|
65
|
+
"OpenStreetMap": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
66
|
+
#"CartoDB Positron": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
67
|
+
#"CartoDB Dark Matter": "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
68
|
+
}
|
69
|
+
|
70
|
+
def _compute_distance_time_metrics(self, df: pd.DataFrame) -> pd.DataFrame:
|
71
|
+
df = df.copy().sort_values(self.settings.date_field).reset_index(drop=True)
|
72
|
+
df[self.settings.date_field] = pd.to_datetime(df[self.settings.date_field])
|
73
|
+
df["prev_lat"] = df[self.settings.lat_col].shift(1)
|
74
|
+
df["prev_lon"] = df[self.settings.lon_col].shift(1)
|
75
|
+
df["prev_time"] = df[self.settings.date_field].shift(1)
|
76
|
+
|
77
|
+
valid_points = df["prev_lat"].notna()
|
78
|
+
df.loc[valid_points, "distance_to_prev"] = df[valid_points].apply(
|
79
|
+
lambda r: geodesic((r["prev_lat"], r["prev_lon"]), (r["latitude"], r["longitude"])).meters, axis=1
|
80
|
+
)
|
81
|
+
df["time_elapsed"] = df[self.settings.date_field] - df["prev_time"]
|
82
|
+
df["cumulative_time"] = df["time_elapsed"].cumsum()
|
83
|
+
df.fillna({"distance_to_prev": 0.0, "time_elapsed": pd.Timedelta(0)}, inplace=True)
|
84
|
+
return df.drop(columns=["prev_lat", "prev_lon", "prev_time"])
|
85
|
+
|
86
|
+
def _format_timedelta(self, td: pd.Timedelta) -> str:
|
87
|
+
total_seconds = int(td.total_seconds())
|
88
|
+
hours, remainder = divmod(total_seconds, 3600)
|
89
|
+
minutes, seconds = divmod(remainder, 60)
|
90
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
91
|
+
|
92
|
+
def _get_midpoint(self, df: pd.DataFrame) -> Tuple[float, float]:
|
93
|
+
all_nodes = [node for path in df["path_nodes"] if path for node in path]
|
94
|
+
if not all_nodes:
|
95
|
+
return (df[self.settings.lat_col].mean(), df[self.settings.lon_col].mean())
|
96
|
+
avg_lat = sum(self.graph.nodes[n]["y"] for n in all_nodes) / len(all_nodes)
|
97
|
+
avg_lon = sum(self.graph.nodes[n]["x"] for n in all_nodes) / len(all_nodes)
|
98
|
+
return (avg_lat, avg_lon)
|
99
|
+
|
100
|
+
def _offset_coordinates(self, coords: List[Tuple[float, float]], offset_m: float) -> List[Tuple[float, float]]:
|
101
|
+
if len(coords) < 2 or offset_m == 0: return coords
|
102
|
+
offset_coords = []
|
103
|
+
for i, (lat, lon) in enumerate(coords):
|
104
|
+
if i == 0: p_prev, p_next = coords[i], coords[i + 1]
|
105
|
+
elif i == len(coords) - 1: p_prev, p_next = coords[i - 1], coords[i]
|
106
|
+
else: p_prev, p_next = coords[i - 1], coords[i + 1]
|
107
|
+
normal_angle = math.atan2(p_next[0] - p_prev[0], p_next[1] - p_prev[1]) + math.pi / 2
|
108
|
+
offset_lat = offset_m / 111111
|
109
|
+
offset_lon = offset_m / (111111 * math.cos(math.radians(lat)))
|
110
|
+
new_lat = lat + offset_lat * math.sin(normal_angle)
|
111
|
+
new_lon = lon + offset_lon * math.cos(normal_angle)
|
112
|
+
offset_coords.append((new_lat, new_lon))
|
113
|
+
return offset_coords
|
114
|
+
|
115
|
+
def _find_furthest_point(self, df: pd.DataFrame) -> Tuple[Optional[int], Optional[int], Optional[pd.Series]]:
|
116
|
+
if df["origin_node"].isnull().all(): return None, None, None
|
117
|
+
start_node = df["origin_node"].iloc[0]
|
118
|
+
start_lat, start_lon = self.graph.nodes[start_node]["y"], self.graph.nodes[start_node]["x"]
|
119
|
+
max_dist, furthest_node, furthest_idx, furthest_row = -1, None, None, None
|
120
|
+
for idx, row in df.iterrows():
|
121
|
+
if not row["path_nodes"]: continue
|
122
|
+
for n in row["path_nodes"]:
|
123
|
+
lat, lon = self.graph.nodes[n]["y"], self.graph.nodes[n]["x"]
|
124
|
+
dist = geodesic((start_lat, start_lon), (lat, lon)).meters
|
125
|
+
if dist > max_dist:
|
126
|
+
max_dist, furthest_node, furthest_idx, furthest_row = dist, n, idx, row
|
127
|
+
return furthest_node, furthest_idx, furthest_row
|
128
|
+
|
129
|
+
def _plot_path(self, m: folium.Map, coords: List[Tuple[float, float]], color: str):
|
130
|
+
if self.settings.use_antpath:
|
131
|
+
AntPath(locations=coords, color=color, weight=self.settings.antpath_weight,
|
132
|
+
delay=self.settings.antpath_delay, dash_array=self.settings.antpath_dash_array,
|
133
|
+
pulse_color=self.settings.antpath_pulse_color, opacity=0.8).add_to(m)
|
134
|
+
else:
|
135
|
+
polyline = folium.PolyLine(coords, color=color, weight=4, opacity=0.6).add_to(m)
|
136
|
+
PolyLineTextPath(polyline, "▶", repeat=True, offset=8, spacing=self.settings.arrow_spacing,
|
137
|
+
attributes={"fill": self.settings.arrow_color, "font-weight": "bold"}).add_to(m)
|
138
|
+
|
139
|
+
def _add_flag_marker(self, m: folium.Map, lat: float, lon: float, color: str, tooltip: str):
|
140
|
+
icon = folium.Icon(color=color, icon="flag", prefix="fa")
|
141
|
+
folium.Marker(location=(lat, lon), icon=icon, tooltip=tooltip).add_to(m)
|
142
|
+
|
143
|
+
def _add_point_markers(self, m: folium.Map, df: pd.DataFrame):
|
144
|
+
action_colors = self.settings.action_colors
|
145
|
+
action_layers = {action: folium.FeatureGroup(name=action) for action in action_colors}
|
146
|
+
action_layers["Others"] = folium.FeatureGroup(name="Others")
|
147
|
+
for idx, row in df.iterrows():
|
148
|
+
action = row.get("action", "Unknown")
|
149
|
+
tooltip = (f"<b>#{idx + 1} {action}</b><br>"
|
150
|
+
f"Time: {row['date_time'].strftime('%H:%M:%S')}<br>"
|
151
|
+
f"Dist from prev: {row['distance_to_prev']:.1f} m<br>"
|
152
|
+
f"Time since prev: {self._format_timedelta(row['time_elapsed'])}")
|
153
|
+
color = action_colors.get(action, "gray")
|
154
|
+
target_layer = action_layers.get(action, action_layers["Others"])
|
155
|
+
folium.CircleMarker(location=(row[self.settings.lat_col], row[self.settings.lon_col]), radius=self.settings.marker_radius,
|
156
|
+
color=color, fill=True, fill_opacity=0.9, tooltip=tooltip).add_to(target_layer)
|
157
|
+
for layer in action_layers.values(): layer.add_to(m)
|
158
|
+
|
159
|
+
# --- MAIN PLOT METHOD ---
|
160
|
+
|
161
|
+
def plot(self, df: pd.DataFrame) -> folium.Map:
|
162
|
+
try:
|
163
|
+
df_dict = df.iloc[0].to_dict()
|
164
|
+
df_dict[self.settings.date_field] = pd.to_datetime(df_dict[self.settings.date_field])
|
165
|
+
RouteDataPoint.model_validate(df_dict)
|
166
|
+
except Exception as e:
|
167
|
+
raise ValueError(f"DataFrame does not match required schema. Error: {e}")
|
168
|
+
|
169
|
+
processed_df = self._compute_distance_time_metrics(df)
|
170
|
+
midpoint = self._get_midpoint(processed_df)
|
171
|
+
|
172
|
+
m = folium.Map(location=midpoint, zoom_start=self.settings.zoom_start,
|
173
|
+
tiles=self.settings.default_tile)
|
174
|
+
for name, url in self.tile_layers.items():
|
175
|
+
if name != self.settings.default_tile:
|
176
|
+
folium.TileLayer(tiles=url, name=name, attr=name).add_to(m)
|
177
|
+
|
178
|
+
furthest_node, furthest_idx, furthest_row = self._find_furthest_point(processed_df)
|
179
|
+
|
180
|
+
if furthest_idx is not None:
|
181
|
+
for idx, row in processed_df.iterrows():
|
182
|
+
if not row["path_nodes"]: continue
|
183
|
+
coords = [(self.graph.nodes[n]["y"], self.graph.nodes[n]["x"]) for n in row["path_nodes"]]
|
184
|
+
is_forward = idx <= furthest_idx
|
185
|
+
path_color = self.settings.forward_color if is_forward else self.settings.return_color
|
186
|
+
path_coords = coords if is_forward else self._offset_coordinates(coords, self.settings.return_offset_m)
|
187
|
+
self._plot_path(m, path_coords, path_color)
|
188
|
+
|
189
|
+
start_node = processed_df["origin_node"].iloc[0]
|
190
|
+
self._add_flag_marker(m, self.graph.nodes[start_node]["y"], self.graph.nodes[start_node]["x"],
|
191
|
+
self.settings.marker_origin_color, f"Start<br>{processed_df[self.settings.date_field].iloc[0].strftime('%H:%M:%S')}")
|
192
|
+
|
193
|
+
end_node = processed_df["dest_node"].iloc[-1]
|
194
|
+
self._add_flag_marker(m, self.graph.nodes[end_node]["y"], self.graph.nodes[end_node]["x"],
|
195
|
+
self.settings.marker_end_color, f"Finish<br>{processed_df[self.settings.date_field].iloc[-1].strftime('%H:%M:%S')}")
|
196
|
+
|
197
|
+
if furthest_node:
|
198
|
+
self._add_flag_marker(m, self.graph.nodes[furthest_node]["y"], self.graph.nodes[furthest_node]["x"],
|
199
|
+
self.settings.furthest_marker_color, f"Furthest Point<br>{furthest_row[self.settings.date_field].strftime('%H:%M:%S')}")
|
200
|
+
|
201
|
+
self._add_point_markers(m, processed_df)
|
202
|
+
folium.LayerControl(collapsed=False).add_to(m)
|
203
|
+
return m
|