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.
Files changed (32) hide show
  1. sibi_dst/__init__.py +7 -1
  2. sibi_dst/df_helper/_artifact_updater_multi_wrapper.py +235 -342
  3. sibi_dst/df_helper/_df_helper.py +417 -117
  4. sibi_dst/df_helper/_parquet_artifact.py +255 -283
  5. sibi_dst/df_helper/backends/parquet/_parquet_options.py +8 -4
  6. sibi_dst/df_helper/backends/sqlalchemy/_db_connection.py +68 -107
  7. sibi_dst/df_helper/backends/sqlalchemy/_db_gatekeeper.py +15 -0
  8. sibi_dst/df_helper/backends/sqlalchemy/_io_dask.py +105 -255
  9. sibi_dst/df_helper/backends/sqlalchemy/_load_from_db.py +90 -42
  10. sibi_dst/df_helper/backends/sqlalchemy/_model_registry.py +192 -0
  11. sibi_dst/df_helper/backends/sqlalchemy/_sql_model_builder.py +122 -72
  12. sibi_dst/osmnx_helper/__init__.py +1 -0
  13. sibi_dst/osmnx_helper/basemaps/route_map_plotter.py +203 -0
  14. sibi_dst/osmnx_helper/route_path_builder.py +97 -0
  15. sibi_dst/osmnx_helper/utils.py +2 -0
  16. sibi_dst/utils/base.py +302 -96
  17. sibi_dst/utils/clickhouse_writer.py +472 -206
  18. sibi_dst/utils/data_utils.py +139 -186
  19. sibi_dst/utils/data_wrapper.py +317 -73
  20. sibi_dst/utils/date_utils.py +1 -0
  21. sibi_dst/utils/df_utils.py +193 -213
  22. sibi_dst/utils/file_utils.py +3 -2
  23. sibi_dst/utils/filepath_generator.py +314 -152
  24. sibi_dst/utils/log_utils.py +581 -242
  25. sibi_dst/utils/manifest_manager.py +60 -76
  26. sibi_dst/utils/parquet_saver.py +33 -27
  27. sibi_dst/utils/phone_formatter.py +88 -95
  28. sibi_dst/utils/update_planner.py +180 -178
  29. sibi_dst/utils/webdav_client.py +116 -166
  30. {sibi_dst-2025.1.12.dist-info → sibi_dst-2025.8.1.dist-info}/METADATA +1 -1
  31. {sibi_dst-2025.1.12.dist-info → sibi_dst-2025.8.1.dist-info}/RECORD +32 -28
  32. {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 MetaData, Engine
5
- from sqlalchemy.orm import DeclarativeBase
6
-
4
+ from sqlalchemy.engine import Engine
7
5
 
8
- class Base(DeclarativeBase):
9
- """Shared declarative base for all ORM models."""
10
- pass
6
+ from ._model_registry import ModelRegistry, apps_label
11
7
 
12
8
 
13
- apps_label = "datacubes.models"
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
- This class is thread-safe and caches reflected table metadata to
20
- improve performance across multiple instantiations.
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
- # NOTE: Using a private SQLAlchemy API. This is a performance
58
- # optimization but may break in future versions of the library.
59
- registered_model = Base.registry._class_registry.get(self.class_name)
60
- if registered_model:
61
- return registered_model
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
+ #
@@ -2,5 +2,6 @@ from .base_osm_map import BaseOsmMap
2
2
  from .utils import PBFHandler
3
3
  __all__ = [
4
4
  "BaseOsmMap",
5
+ #"MapConfig",
5
6
  "PBFHandler",
6
7
  ]
@@ -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