|
| 1 | +from typing import TYPE_CHECKING, Any, Dict, List |
| 2 | + |
| 3 | +from DashAI.back.core.artifacts import Artifact, PlotlyArtifact |
| 4 | +from DashAI.back.core.schema_fields import bool_field, schema_field |
| 5 | +from DashAI.back.core.utils import MultilingualString |
| 6 | +from DashAI.back.dependencies.database.models import Explorer, Notebook |
| 7 | +from DashAI.back.exploration.base_explorer import BaseExplorerSchema |
| 8 | +from DashAI.back.exploration.relationship_explorer import RelationshipExplorer |
| 9 | +from DashAI.back.types.value_types import Date, Float, Integer |
| 10 | + |
| 11 | +if TYPE_CHECKING: |
| 12 | + from pathlib import Path |
| 13 | + |
| 14 | + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset |
| 15 | + |
| 16 | +# Semantic types that can be plotted as a series against time. |
| 17 | +_VALUE_TYPES = ("Float", "Integer") |
| 18 | + |
| 19 | + |
| 20 | +class TimeSeriesPlotSchema(BaseExplorerSchema): |
| 21 | + """Schema for TimeSeriesPlotExplorer hyperparameters.""" |
| 22 | + |
| 23 | + markers: schema_field( |
| 24 | + bool_field(), |
| 25 | + False, |
| 26 | + description=MultilingualString( |
| 27 | + en=( |
| 28 | + "Draw a point at each observation as well as the line. Useful " |
| 29 | + "for short or irregular series, where the line alone hides " |
| 30 | + "how many readings there actually are." |
| 31 | + ), |
| 32 | + es=( |
| 33 | + "Dibuja un punto en cada observacion ademas de la linea. Util " |
| 34 | + "para series cortas o irregulares, donde la linea por si sola " |
| 35 | + "oculta cuantas mediciones hay en realidad." |
| 36 | + ), |
| 37 | + pt=( |
| 38 | + "Desenha um ponto em cada observacao alem da linha. Util para " |
| 39 | + "series curtas ou irregulares, nas quais a linha sozinha " |
| 40 | + "esconde quantas leituras existem de fato." |
| 41 | + ), |
| 42 | + de=( |
| 43 | + "Zeichnet zusaetzlich zur Linie einen Punkt pro Beobachtung. " |
| 44 | + "Nuetzlich bei kurzen oder unregelmaessigen Reihen, wo die " |
| 45 | + "Linie allein verbirgt, wie viele Messwerte es wirklich gibt." |
| 46 | + ), |
| 47 | + zh=( |
| 48 | + "除折线外,在每个观测点绘制一个标记。" |
| 49 | + "对于较短或不规则的序列很有用," |
| 50 | + "因为仅有折线会掩盖实际的观测数量。" |
| 51 | + ), |
| 52 | + ), |
| 53 | + alias=MultilingualString( |
| 54 | + en="Show markers", |
| 55 | + es="Mostrar marcadores", |
| 56 | + pt="Mostrar marcadores", |
| 57 | + de="Markierungen anzeigen", |
| 58 | + zh="显示标记", |
| 59 | + ), |
| 60 | + ) # type: ignore |
| 61 | + |
| 62 | + |
| 63 | +class TimeSeriesPlotExplorer(RelationshipExplorer): |
| 64 | + """Plot one or more numeric columns against a date column, over time. |
| 65 | +
|
| 66 | + Select the date column plus the series to look at. The dates are read with |
| 67 | + the format the column already carries, sorted, and handed to the plot as |
| 68 | + real datetimes, so the horizontal axis is a genuine time axis: gaps show |
| 69 | + up as gaps and irregular spacing is visible rather than flattened into |
| 70 | + evenly spaced categories. |
| 71 | +
|
| 72 | + This is the plot to look at before forecasting anything, since trend, |
| 73 | + seasonality, level shifts, missing stretches and outliers are all obvious |
| 74 | + here and nearly invisible in a summary table. |
| 75 | +
|
| 76 | + Several numeric columns can be selected at once and are drawn as separate |
| 77 | + lines sharing the time axis. |
| 78 | + """ |
| 79 | + |
| 80 | + DISPLAY_NAME = MultilingualString( |
| 81 | + en="Time Series Plot", |
| 82 | + es="Grafico de Serie Temporal", |
| 83 | + pt="Grafico de Serie Temporal", |
| 84 | + de="Zeitreihendiagramm", |
| 85 | + zh="时间序列图", |
| 86 | + ) |
| 87 | + DESCRIPTION = MultilingualString( |
| 88 | + en=( |
| 89 | + "Draws one or more numeric columns against a date column as lines " |
| 90 | + "over time. The dates are sorted and plotted on a real time axis, " |
| 91 | + "so gaps and uneven spacing are visible instead of being " |
| 92 | + "flattened into evenly spaced points. Select the date column and " |
| 93 | + "the columns to plot." |
| 94 | + ), |
| 95 | + es=( |
| 96 | + "Dibuja una o mas columnas numericas frente a una columna de fecha " |
| 97 | + "como lineas en el tiempo. Las fechas se ordenan y se grafican en " |
| 98 | + "un eje temporal real, de modo que los huecos y el espaciado " |
| 99 | + "irregular quedan visibles en lugar de aplanarse en puntos " |
| 100 | + "equidistantes. Selecciona la columna de fecha y las columnas a " |
| 101 | + "graficar." |
| 102 | + ), |
| 103 | + pt=( |
| 104 | + "Desenha uma ou mais colunas numericas em relacao a uma coluna de " |
| 105 | + "data como linhas ao longo do tempo. As datas sao ordenadas e " |
| 106 | + "plotadas em um eixo temporal real, de modo que lacunas e " |
| 107 | + "espacamento irregular ficam visiveis em vez de serem achatados em " |
| 108 | + "pontos equidistantes. Selecione a coluna de data e as colunas a " |
| 109 | + "plotar." |
| 110 | + ), |
| 111 | + de=( |
| 112 | + "Zeichnet eine oder mehrere numerische Spalten gegen eine " |
| 113 | + "Datumsspalte als Linien ueber die Zeit. Die Daten werden sortiert " |
| 114 | + "und auf einer echten Zeitachse dargestellt, sodass Luecken und " |
| 115 | + "ungleichmaessige Abstaende sichtbar bleiben, statt zu " |
| 116 | + "gleichmaessigen Punkten zusammengedrueckt zu werden. Waehlen Sie " |
| 117 | + "die Datumsspalte und die zu zeichnenden Spalten aus." |
| 118 | + ), |
| 119 | + zh=( |
| 120 | + "将一列或多列数值列相对于日期列绘制为随时间变化的折线。" |
| 121 | + "日期会被排序并绘制在真实的时间轴上," |
| 122 | + "因此间隔和不规则的时间间距清晰可见,而不会被压缩为等距的点。" |
| 123 | + "请选择日期列以及要绘制的列。" |
| 124 | + ), |
| 125 | + ) |
| 126 | + |
| 127 | + SCHEMA = TimeSeriesPlotSchema |
| 128 | + metadata: Dict[str, Any] = { |
| 129 | + "allowed_types": [Date, Float, Integer], |
| 130 | + "allowed_dtypes": [], |
| 131 | + "input_cardinality": {"min": 2}, |
| 132 | + } |
| 133 | + |
| 134 | + def __init__(self, **kwargs) -> None: |
| 135 | + """Initialize the explorer. |
| 136 | +
|
| 137 | + Parameters |
| 138 | + ---------- |
| 139 | + **kwargs |
| 140 | + Configuration keyword arguments. Recognized keys: |
| 141 | + markers (bool, optional): Draw a point per observation in addition |
| 142 | + to the line. Defaults to False. |
| 143 | + """ |
| 144 | + self.markers = bool(kwargs.get("markers", False)) |
| 145 | + super().__init__(**kwargs) |
| 146 | + |
| 147 | + @classmethod |
| 148 | + def validate_columns( |
| 149 | + cls, explorer_info: Explorer, column_spec: Dict[str, Dict[str, str]] |
| 150 | + ) -> bool: |
| 151 | + """Check the selection is one date column plus at least one series. |
| 152 | +
|
| 153 | + The inherited check only asks that every column is of an allowed type, |
| 154 | + which would pass a selection of two numbers and no date, or of two |
| 155 | + dates and no series. Neither can be plotted, so both are refused here |
| 156 | + rather than failing later with an unhelpful error. |
| 157 | +
|
| 158 | + Parameters |
| 159 | + ---------- |
| 160 | + explorer_info : Explorer |
| 161 | + The database record for the explorer instance, including the |
| 162 | + selected columns. |
| 163 | + column_spec : Dict[str, Dict[str, str]] |
| 164 | + A mapping from column name to a dict with at least ``"type"`` and |
| 165 | + ``"dtype"``. |
| 166 | +
|
| 167 | + Returns |
| 168 | + ------- |
| 169 | + bool |
| 170 | + True if the selection holds exactly one Date column and at least |
| 171 | + one numeric column, and the inherited checks also pass. |
| 172 | + """ |
| 173 | + if not super().validate_columns(explorer_info, column_spec): |
| 174 | + return False |
| 175 | + |
| 176 | + types = [ |
| 177 | + column_spec.get(column["columnName"], {}).get("type", "") |
| 178 | + for column in explorer_info.columns |
| 179 | + ] |
| 180 | + return types.count("Date") == 1 and any(t in _VALUE_TYPES for t in types) |
| 181 | + |
| 182 | + def launch_exploration(self, dataset: "DashAIDataset", explorer_info: Explorer): |
| 183 | + """Draw the selected series against the selected date column. |
| 184 | +
|
| 185 | + Parameters |
| 186 | + ---------- |
| 187 | + dataset : DashAIDataset |
| 188 | + The prepared dataset holding the selected columns. |
| 189 | + explorer_info : Explorer |
| 190 | + Explorer record with the column names and optional display name. |
| 191 | +
|
| 192 | + Returns |
| 193 | + ------- |
| 194 | + plotly.graph_objects.Figure |
| 195 | + An interactive line plot with a time axis. |
| 196 | +
|
| 197 | + Raises |
| 198 | + ------ |
| 199 | + ValueError |
| 200 | + If no selected column is a Date, or if the date values do not |
| 201 | + match the format the column declares. |
| 202 | + """ |
| 203 | + import plotly.express as px |
| 204 | + |
| 205 | + from DashAI.back.types.date_utils import DEFAULT_DATE_FORMAT, parse_date_column |
| 206 | + |
| 207 | + columns = [column["columnName"] for column in explorer_info.columns] |
| 208 | + date_columns = [ |
| 209 | + name for name in columns if isinstance(dataset.types.get(name), Date) |
| 210 | + ] |
| 211 | + if not date_columns: |
| 212 | + raise ValueError( |
| 213 | + "TimeSeriesPlotExplorer needs a Date column among the selected " |
| 214 | + f"columns, got {', '.join(columns)}." |
| 215 | + ) |
| 216 | + |
| 217 | + date_column = date_columns[0] |
| 218 | + value_columns = [name for name in columns if name != date_column] |
| 219 | + |
| 220 | + frame = dataset.to_pandas() |
| 221 | + # Read with the format the column already declares rather than |
| 222 | + # guessing, so the plot can never disagree with the stored type. A |
| 223 | + # column whose format is wrong raises here instead of drawing a |
| 224 | + # plausible but wrong picture. |
| 225 | + date_format = ( |
| 226 | + getattr(dataset.types[date_column], "format", None) or DEFAULT_DATE_FORMAT |
| 227 | + ) |
| 228 | + frame[date_column] = parse_date_column(frame[date_column], date_format) |
| 229 | + frame = frame.sort_values(date_column) |
| 230 | + |
| 231 | + figure = px.line( |
| 232 | + frame, |
| 233 | + x=date_column, |
| 234 | + y=value_columns, |
| 235 | + markers=self.markers, |
| 236 | + title=f"{', '.join(value_columns)} over {date_column}", |
| 237 | + ) |
| 238 | + figure.update_layout(xaxis_title=date_column, yaxis_title="") |
| 239 | + |
| 240 | + if explorer_info.name is not None and explorer_info.name != "": |
| 241 | + figure.update_layout(title=f"{explorer_info.name}") |
| 242 | + |
| 243 | + return figure |
| 244 | + |
| 245 | + def save_notebook( |
| 246 | + self, |
| 247 | + __notebook_info__: Notebook, |
| 248 | + explorer_info: Explorer, |
| 249 | + save_path: "Path", |
| 250 | + result: Any, |
| 251 | + ) -> str: |
| 252 | + """Save the figure to disk (JSON content, ``.pickle`` extension). |
| 253 | +
|
| 254 | + Notes |
| 255 | + ----- |
| 256 | + Despite the ``.pickle`` file extension, the file is written using |
| 257 | + ``write_json`` and contains JSON-serialized Plotly figure data. This |
| 258 | + matches every other plot explorer. |
| 259 | +
|
| 260 | + Parameters |
| 261 | + ---------- |
| 262 | + __notebook_info__ : Notebook |
| 263 | + The notebook database record (unused). |
| 264 | + explorer_info : Explorer |
| 265 | + The explorer record used for filename generation. |
| 266 | + save_path : Path |
| 267 | + Directory where the file will be saved. |
| 268 | + result : Any |
| 269 | + The Plotly figure returned by ``launch_exploration``. |
| 270 | +
|
| 271 | + Returns |
| 272 | + ------- |
| 273 | + str |
| 274 | + The path of the saved file as a POSIX string. |
| 275 | + """ |
| 276 | + import os |
| 277 | + from pathlib import Path |
| 278 | + |
| 279 | + filename = f"{explorer_info.id}.pickle" |
| 280 | + path = Path(os.path.join(save_path, filename)) |
| 281 | + |
| 282 | + result.write_json(path.as_posix()) |
| 283 | + return path.as_posix() |
| 284 | + |
| 285 | + def get_results( |
| 286 | + self, exploration_path: str, options: Dict[str, Any] |
| 287 | + ) -> List[Artifact]: |
| 288 | + """Load and return the saved figure for the frontend. |
| 289 | +
|
| 290 | + Parameters |
| 291 | + ---------- |
| 292 | + exploration_path : str |
| 293 | + Path to the JSON file saved by ``save_notebook``. |
| 294 | + options : Dict[str, Any] |
| 295 | + Rendering options from the frontend (unused). |
| 296 | +
|
| 297 | + Returns |
| 298 | + ------- |
| 299 | + List[Artifact] |
| 300 | + A single-element list with the plotly artifact of the saved figure. |
| 301 | + """ |
| 302 | + with open(exploration_path, "r", encoding="utf-8") as f: |
| 303 | + result = f.read() |
| 304 | + |
| 305 | + return [PlotlyArtifact(payload=result)] |
0 commit comments