File size: 11,643 Bytes
e201ae4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""charts — shared chart-envelope kit + auto-chart builders (analytical tools round).

The house style (colorway, layout preset, JSON-safe coercion) lives here as the
single source of truth. `visualization.py`'s `render_chart` tool imports this kit,
and the analytical tools call the builders below to attach an auto-chart to their
output ("sepaket chart"): each builder returns the `dataeyond.chart.v1` envelope
(SPINE_V2_PLAN §4.2) the FE renders with plotly.js.

Deliberately NO plotly import — every builder returns a hand-built dict (locked
decision, DEV_PLAN deferred row #26), exactly like `render_chart`. Style is the
fixed module preset, never a planner/LLM choice.

Builders (envelope shape `{schema, chart_type, title, plotly:{data, layout}}`):
  - box_chart               distribution / spread (descriptive, anomaly context)
  - pareto_chart            80/20 contribution, bars + cumulative-% line (analyze_pareto)
  - cluster_scatter_chart   points colored per cluster (analyze_cluster)
  - anomaly_scatter_chart   points with anomalies highlighted (analyze_anomaly)

The wiring that carries these envelopes from a tool into the turn's persisted
charts (ToolOutput.meta -> the chat_handler save loop) is a spine change outside
this module and is added separately.
"""

from __future__ import annotations

import datetime as _dt
from decimal import Decimal
from typing import Any

import numpy as np
import pandas as pd

from src.tools.analytics.descriptive import ColumnNotFoundError

SCHEMA = "dataeyond.chart.v1"

# --------------------------------------------------------------------------- #
# House style preset — fixed by design, not a planner argument.
#
# Colorway = the dataviz reference categorical palette (8 slots, light mode).
# The slot ORDER is the colorblind-safety mechanism (validated: worst adjacent
# CVD deltaE 24.2) — series take slots in this fixed order, never re-picked.
# Backgrounds are transparent so the chart inherits the FE surface.
# --------------------------------------------------------------------------- #
COLORWAY = [
    "#2a78d6",  # blue
    "#1baf7a",  # aqua
    "#eda100",  # yellow
    "#008300",  # green
    "#4a3aa7",  # violet
    "#e34948",  # red
    "#e87ba4",  # magenta
    "#eb6834",  # orange
]

_INK_SECONDARY = "#52514e"  # body text
_INK_MUTED = "#898781"      # axis titles / tick labels (mode-neutral)
_GRIDLINE = "#e1e0d9"       # hairline grid
_BASELINE = "#c3c2b7"       # axis line
_HIGHLIGHT = COLORWAY[5]    # red — the "look here" ink for anomaly points

LAYOUT_PRESET: dict[str, object] = {
    "colorway": COLORWAY,
    "font": {
        "family": 'system-ui, -apple-system, "Segoe UI", sans-serif',
        "size": 13,
        "color": _INK_SECONDARY,
    },
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)",
    "margin": {"t": 48, "r": 16, "b": 48, "l": 56},
    "bargap": 0.25,
}

# Mark specs: thin marks — 2px lines, 8px markers.
_LINE_STYLE = {"width": 2}
_MARKER_STYLE = {"size": 8}


# --------------------------------------------------------------------------- #
# JSON-safe coercion + axis chrome (shared with render_chart).
# --------------------------------------------------------------------------- #
def _clean(value: object) -> object:
    """One JSON-safe scalar: numpy -> Python, NaN/NaT -> None, dates -> ISO strings.

    Chart specs are persisted as JSONB and shipped to the FE, so every value in a
    trace array must survive json.dumps.
    """
    if value is None or value is pd.NaT:
        return None
    if isinstance(value, float) and np.isnan(value):
        return None
    if hasattr(value, "item"):  # numpy scalar (incl. Timestamp via below)
        value = value.item()
        if isinstance(value, float) and np.isnan(value):
            return None
    if isinstance(value, Decimal):
        return float(value)
    if isinstance(value, pd.Timestamp):
        return value.isoformat()
    if isinstance(value, _dt.datetime | _dt.date):
        return value.isoformat()
    if isinstance(value, bool | int | float | str):
        return value
    return str(value)


def _values(col: pd.Series) -> list[object]:
    return [_clean(v) for v in col.tolist()]


def _series_label(value: object) -> str:
    """Trace name for one series group; null groups get an explicit label."""
    cleaned = _clean(value)
    return "(missing)" if cleaned is None else str(cleaned)


def _axis(label: str) -> dict[str, object]:
    """Recessive axis chrome + the column name as the axis title."""
    return {
        "title": {"text": label, "font": {"color": _INK_MUTED}},
        "tickfont": {"color": _INK_MUTED},
        "gridcolor": _GRIDLINE,
        "linecolor": _BASELINE,
        "automargin": True,
    }


# --------------------------------------------------------------------------- #
# Envelope + layout assembly.
# --------------------------------------------------------------------------- #
def _require(df: pd.DataFrame, cols: list[str]) -> None:
    missing = [c for c in cols if c not in df.columns]
    if missing:
        raise ColumnNotFoundError(f"columns not found: {missing}")


def _layout(
    title: str,
    *,
    x_label: str | None = None,
    y_label: str | None = None,
    showlegend: bool = False,
    extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
    layout: dict[str, Any] = {**LAYOUT_PRESET, "title": {"text": title}, "showlegend": showlegend}
    if x_label is not None:
        layout["xaxis"] = _axis(x_label)
    if y_label is not None:
        layout["yaxis"] = _axis(y_label)
    if extra:
        layout.update(extra)
    return layout


def envelope(
    chart_type: str, title: str, traces: list[dict[str, Any]], layout: dict[str, Any]
) -> dict[str, Any]:
    """Wrap traces + layout in the `dataeyond.chart.v1` envelope (SPINE_V2_PLAN §4.2)."""
    return {
        "schema": SCHEMA,
        "chart_type": chart_type,
        "title": title,
        "plotly": {"data": traces, "layout": layout},
    }


# --------------------------------------------------------------------------- #
# Auto-chart builders.
# --------------------------------------------------------------------------- #
def box_chart(
    df: pd.DataFrame,
    value_column: str,
    group_column: str | None = None,
    title: str | None = None,
) -> dict[str, Any]:
    """Box plot of `value_column`, optionally one box per `group_column` value."""
    _require(df, [value_column, *([group_column] if group_column else [])])
    chart_title = title or (
        f"Distribution of {value_column}" + (f" by {group_column}" if group_column else "")
    )
    trace: dict[str, Any] = {
        "type": "box",
        "y": _values(df[value_column]),
        "name": value_column,
        "marker": {"color": COLORWAY[0]},
    }
    if group_column:
        trace["x"] = _values(df[group_column])
    layout = _layout(chart_title, x_label=group_column, y_label=value_column)
    return envelope("box", chart_title, [trace], layout)


def pareto_chart(
    df: pd.DataFrame,
    category_column: str,
    value_column: str,
    title: str | None = None,
    top_n: int | None = None,
) -> dict[str, Any]:
    """Pareto (80/20): bars of `value_column` per category, descending, plus a
    cumulative-% line on a secondary axis and an 80% reference line. Rows are
    summed per category; the tail past `top_n` is folded into an 'Other' bar."""
    _require(df, [category_column, value_column])
    values = pd.to_numeric(df[value_column], errors="coerce").fillna(0.0)
    agg = values.groupby(df[category_column], dropna=False).sum().sort_values(ascending=False)
    if top_n is not None and len(agg) > top_n:
        head = agg.iloc[:top_n]
        agg = pd.concat([head, pd.Series({"Other": float(agg.iloc[top_n:].sum())})])
    total = float(agg.sum()) or 1.0

    categories = [_series_label(c) for c in agg.index]
    bar_values = [float(v) for v in agg.to_numpy()]
    cum_pct = [round(v, 2) for v in (agg.cumsum() / total * 100.0).to_numpy()]
    chart_title = title or f"Pareto of {value_column} by {category_column}"

    bar = {"type": "bar", "x": categories, "y": bar_values, "name": value_column}
    line = {
        "type": "scatter",
        "mode": "lines+markers",
        "x": categories,
        "y": cum_pct,
        "name": "Cumulative %",
        "yaxis": "y2",
        "line": {**_LINE_STYLE, "color": _HIGHLIGHT},
        "marker": {**_MARKER_STYLE, "color": _HIGHLIGHT},
    }
    layout = _layout(
        chart_title,
        x_label=category_column,
        y_label=value_column,
        showlegend=True,
        extra={
            "yaxis2": {
                "title": {"text": "Cumulative %", "font": {"color": _INK_MUTED}},
                "tickfont": {"color": _INK_MUTED},
                "overlaying": "y",
                "side": "right",
                "range": [0, 105],
                "showgrid": False,
            },
            "shapes": [
                {
                    "type": "line",
                    "xref": "paper",
                    "x0": 0,
                    "x1": 1,
                    "yref": "y2",
                    "y0": 80,
                    "y1": 80,
                    "line": {"color": _BASELINE, "width": 1, "dash": "dot"},
                }
            ],
        },
    )
    return envelope("pareto", chart_title, [bar, line], layout)


def cluster_scatter_chart(
    df: pd.DataFrame,
    x: str,
    y: str,
    cluster_column: str,
    title: str | None = None,
) -> dict[str, Any]:
    """Scatter of `x` vs `y`, one colorway-slotted trace per `cluster_column` value."""
    _require(df, [x, y, cluster_column])
    chart_title = title or f"{y} vs {x} by cluster"
    # sort=True keeps trace order (and colorway slots) deterministic across runs.
    traces = [
        {
            "type": "scatter",
            "mode": "markers",
            "x": _values(group[x]),
            "y": _values(group[y]),
            "name": _series_label(value),
            "marker": dict(_MARKER_STYLE),
        }
        for value, group in df.groupby(cluster_column, dropna=False, sort=True)
    ]
    layout = _layout(chart_title, x_label=x, y_label=y, showlegend=True)
    return envelope("cluster_scatter", chart_title, traces, layout)


def anomaly_scatter_chart(
    df: pd.DataFrame,
    x: str,
    y: str,
    flag_column: str,
    title: str | None = None,
) -> dict[str, Any]:
    """Scatter of `x` vs `y` with rows where `flag_column` is truthy drawn as
    larger red 'anomaly' markers over the normal points."""
    _require(df, [x, y, flag_column])
    flags = df[flag_column].astype(bool)
    normal, anomalies = df[~flags], df[flags]
    chart_title = title or f"{y} vs {x} (anomalies highlighted)"

    traces: list[dict[str, Any]] = [
        {
            "type": "scatter",
            "mode": "markers",
            "x": _values(normal[x]),
            "y": _values(normal[y]),
            "name": "normal",
            "marker": {**_MARKER_STYLE, "color": COLORWAY[0]},
        }
    ]
    if len(anomalies):
        traces.append(
            {
                "type": "scatter",
                "mode": "markers",
                "x": _values(anomalies[x]),
                "y": _values(anomalies[y]),
                "name": "anomaly",
                "marker": {
                    "size": 11,
                    "color": _HIGHLIGHT,
                    "line": {"width": 1, "color": "#ffffff"},
                },
            }
        )
    layout = _layout(chart_title, x_label=x, y_label=y, showlegend=True)
    return envelope("scatter", chart_title, traces, layout)