"""自适应报告可视化:数据形态驱动的图表推荐引擎(adaptive-report-visualization)。 本模块是**纯 Python、确定性、不接收 svc/LLM 句柄、不 import matplotlib/numpy** 的底座 组件。它把 ``compute`` 阶段产出的确定性结构化结果(``ComputeResult.summary`` / ``figures``)映射为有序的图表 / 表格方案(:class:`VisualizationPlan`),交由既有 ``ChartService`` 渲染、``ReportService`` / ``docx_export`` 注入。 合规底线(与 spec requirements 一致): - **数值只来自 compute**:推荐引擎只在既有数值之上做"形态判别 → 选图 → 排序 → 预算", 绝不选数 / 造数 / 改数;参考线只用 compute 解析出的限度 / AV / 货架期数值。 - **可降级、可测试**:不依赖绘图库即可完成形态分类 / 选图 / 排序,可被纯逻辑单测覆盖。 - **确定性 / 幂等 / 可序列化**:同输入产出等价方案;重复排序结果不变;``to_dict`` / ``from_dict`` 往返一致。 本文件实现任务 1 的数据模型与序列化;分类器 / 适配器 / 推荐编排在后续任务补齐。 """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Any, Optional # --------------------------------------------------------------------------- # 枚举 # --------------------------------------------------------------------------- class ShapeSignature(str, Enum): """数据形态签名(离散标签)。""" TIME_SERIES = "time_series" GROUPED_SINGLE_METRIC = "grouped_single_metric" REPEATED_UNIT_DISTRIBUTION = "repeated_unit_distribution" CROSS_ATTRIBUTE_COMPLIANCE = "cross_attribute_compliance" BIVARIATE = "bivariate" SINGLE_VALUE_VS_LIMIT = "single_value_vs_limit" UNKNOWN = "unknown" class ChartType(str, Enum): """图型。""" TREND_BAND = "trend_band" GROUPED_BAR = "grouped_bar" GROUPED_DOT = "grouped_dot" DISTRIBUTION_DOT = "distribution_dot" BOX_PLOT = "box_plot" STATUS_MATRIX = "status_matrix" SCATTER = "scatter" BULLET = "bullet" TABLE = "table" # 降级表格 def _coerce_enum(enum_cls, value, default): if isinstance(value, enum_cls): return value try: return enum_cls(str(value)) except (ValueError, TypeError): return default # --------------------------------------------------------------------------- # DataBlock:各 Skill summary 归一后的中间块(分类器只读它) # --------------------------------------------------------------------------- @dataclass class DataBlock: """SummaryAdapter 把各 Skill 的 summary 归一成的中间块。 其中所有数值字段(``times`` / ``series`` / ``groups`` / ``units`` / 限度)均为对 ``ComputeResult`` 既有数值的**逐字拷贝**,不新增、不取整、不插值(需求 17.3)。 """ block_id: str title: str = "" source_table: str = "" times: Optional[list] = None # time_series:时间点 series: Optional[list] = None # [{label, values, ...}] groups: Optional[list] = None # [{label, value, within_spec, risk, limit}] units: Optional[list] = None # repeated_unit_distribution:逐单位值 limit_low: Optional[float] = None limit_high: Optional[float] = None limit_text: str = "" acceptance_value: Optional[dict] = None # 含量均匀度 AV 信息 attribute: str = "" intent_hint: str = "" def to_dict(self) -> dict: return { "block_id": self.block_id, "title": self.title, "source_table": self.source_table, "times": list(self.times) if self.times is not None else None, "series": [dict(s) for s in self.series] if self.series is not None else None, "groups": [dict(g) for g in self.groups] if self.groups is not None else None, "units": list(self.units) if self.units is not None else None, "limit_low": self.limit_low, "limit_high": self.limit_high, "limit_text": self.limit_text, "acceptance_value": dict(self.acceptance_value) if self.acceptance_value else None, "attribute": self.attribute, "intent_hint": self.intent_hint, } @staticmethod def from_dict(d: dict) -> "DataBlock": d = d or {} return DataBlock( block_id=str(d.get("block_id", "")), title=str(d.get("title", "")), source_table=str(d.get("source_table", "")), times=list(d["times"]) if d.get("times") is not None else None, series=[dict(s) for s in d["series"]] if d.get("series") is not None else None, groups=[dict(g) for g in d["groups"]] if d.get("groups") is not None else None, units=list(d["units"]) if d.get("units") is not None else None, limit_low=d.get("limit_low"), limit_high=d.get("limit_high"), limit_text=str(d.get("limit_text", "")), acceptance_value=dict(d["acceptance_value"]) if d.get("acceptance_value") else None, attribute=str(d.get("attribute", "")), intent_hint=str(d.get("intent_hint", "")), ) # --------------------------------------------------------------------------- # ChartSpec:待渲染图表的确定性规格(不含 LLM 生成的数值) # --------------------------------------------------------------------------- @dataclass class ChartSpec: chart_type: ChartType block_id: str # 引用 DataBlock / figures key title: str = "" caption_key: str = "" # 图注(文字,渲染时填充) reference_lines: list = field(default_factory=list) # [{value,label,kind}],仅来自 compute risk_colored: bool = False redundant_marks: bool = True # 无障碍:状态附符号/文字(需求 15) companion_table_block_id: str = "" # 配套源数据表(可审计,需求 14) aggregated_from: list = field(default_factory=list) # 聚合自哪些分组(需求 16) degraded_from: Optional[ChartType] = None degrade_reason: str = "" def to_dict(self) -> dict: return { "chart_type": self.chart_type.value, "block_id": self.block_id, "title": self.title, "caption_key": self.caption_key, "reference_lines": [dict(r) for r in self.reference_lines], "risk_colored": bool(self.risk_colored), "redundant_marks": bool(self.redundant_marks), "companion_table_block_id": self.companion_table_block_id, "aggregated_from": list(self.aggregated_from), "degraded_from": self.degraded_from.value if self.degraded_from else None, "degrade_reason": self.degrade_reason, } @staticmethod def from_dict(d: dict) -> "ChartSpec": d = d or {} df = d.get("degraded_from") return ChartSpec( chart_type=_coerce_enum(ChartType, d.get("chart_type"), ChartType.TABLE), block_id=str(d.get("block_id", "")), title=str(d.get("title", "")), caption_key=str(d.get("caption_key", "")), reference_lines=[dict(r) for r in (d.get("reference_lines") or [])], risk_colored=bool(d.get("risk_colored", False)), redundant_marks=bool(d.get("redundant_marks", True)), companion_table_block_id=str(d.get("companion_table_block_id", "")), aggregated_from=list(d.get("aggregated_from") or []), degraded_from=_coerce_enum(ChartType, df, None) if df else None, degrade_reason=str(d.get("degrade_reason", "")), ) # --------------------------------------------------------------------------- # VisualizationPlan:有序 ChartSpec 列表 + 降级/忽略记录 # --------------------------------------------------------------------------- @dataclass class VisualizationPlan: specs: list = field(default_factory=list) # list[ChartSpec],有序 notes: list = field(default_factory=list) # 降级/忽略原因(可审计) def to_dict(self) -> dict: return { "specs": [s.to_dict() for s in self.specs], "notes": list(self.notes), } @staticmethod def from_dict(d: dict) -> "VisualizationPlan": d = d or {} return VisualizationPlan( specs=[ChartSpec.from_dict(s) for s in (d.get("specs") or [])], notes=[str(n) for n in (d.get("notes") or [])], ) __all__ = [ "ShapeSignature", "ChartType", "DataBlock", "ChartSpec", "VisualizationPlan", "build_data_blocks", "classify_block", "map_block_to_spec", "MIN_TREND_POINTS", "MIN_GROUPS", "MIN_UNITS", "VisualizationRecommender", "figures_from_plan", ] # =========================================================================== # 任务 2:SummaryAdapter —— summary → list[DataBlock] # =========================================================================== def _is_number(v: Any) -> bool: return isinstance(v, (int, float)) and not isinstance(v, bool) def _status_of(within_spec: Optional[bool]) -> str: """合规布尔 → 三态标签(用于状态矩阵的冗余编码)。""" if within_spec is True: return "pass" if within_spec is False: return "fail" return "na" def _descriptive_blocks(summary: dict) -> list["DataBlock"]: """从 descriptive_summary 的 ``groups`` 产出 DataBlock(逐字拷贝数值)。 产出三类: - 含量均匀度等"重复单位分布":单组 values≥3 且带 acceptance_value。 - "跨属性合规":汇总所有 within_spec 非空的分组为一个状态矩阵块。 - "分组×单指标":同一属性跨多分组(规格/批次)的均值聚合为一张多分组图(需求 16.2)。 """ groups = summary.get("groups") or [] blocks: list[DataBlock] = [] # 1) 重复单位分布(逐单位值)。 for g in groups: values = g.get("values") or [] av = g.get("acceptance_value") numeric = [v for v in values if _is_number(v)] if av and len(numeric) >= 3: strength = str(g.get("strength", "") or "") attr = str(g.get("attribute", "") or "") blocks.append(DataBlock( block_id=f"dist::{attr}::{strength}::{g.get('table','')}", title=f"{attr} {strength}".strip(), source_table=str(g.get("table", "") or ""), units=list(numeric), # 逐字拷贝 acceptance_value=dict(av), attribute=attr, )) # 2) 跨属性合规状态矩阵(汇总所有有判定的分组)。 compliance_rows = [] for g in groups: ws = g.get("within_spec") if ws is None: continue label = f"{g.get('attribute','')} {g.get('strength','')}".strip() compliance_rows.append({ "label": label, "attribute": str(g.get("attribute", "") or ""), "strength": str(g.get("strength", "") or ""), "within_spec": ws, "status": _status_of(ws), "spec_limit": g.get("spec_limit"), }) if compliance_rows: blocks.append(DataBlock( block_id="compliance::all", title="限度符合性", groups=compliance_rows, )) # 3) 分组×单指标:同属性跨多分组的均值聚合(聚合优先,需求 16.2)。 by_attr: dict[str, list[dict]] = {} for g in groups: if g.get("acceptance_value"): continue # CU 已单独成图 mean = g.get("mean") if not _is_number(mean): continue attr = str(g.get("attribute", "") or "") if not attr: continue by_attr.setdefault(attr, []).append({ "label": str(g.get("strength", "") or g.get("batch", "") or attr), "value": mean, # 逐字拷贝 "within_spec": g.get("within_spec"), "spec_limit": g.get("spec_limit"), }) for attr, rows in by_attr.items(): if len(rows) < 2: continue # 单分组无可比性,交由门槛/降级处理 blocks.append(DataBlock( block_id=f"grouped::{attr}", title=attr, attribute=attr, groups=rows, )) return blocks def _generic_timeseries_blocks(summary: dict) -> list["DataBlock"]: """从通用 ``data_overview.rows``(稳定性等)产出时序 DataBlock。 每行形如 ``{batch, condition, cqa, timepoints:[...], values:[...]}``。 """ overview = summary.get("data_overview") or {} rows = overview.get("rows") or [] blocks: list[DataBlock] = [] for i, r in enumerate(rows): tps = r.get("timepoints") or [] vals = r.get("values") or [] if len(tps) < 2 or len(vals) != len(tps): continue label = f"{r.get('batch','')} {r.get('condition','')} {r.get('cqa','')}".strip() blocks.append(DataBlock( block_id=f"ts::{i}::{label}", title=label, times=list(tps), # 逐字拷贝 series=[{"label": str(r.get("cqa", "") or label), "values": list(vals)}], attribute=str(r.get("cqa", "") or ""), )) return blocks def build_data_blocks(summary: dict, *, skill_id: str = "", skill: Any = None) -> list["DataBlock"]: """把任意 Skill 的 ``summary`` 归一为 DataBlock 列表(SummaryAdapter 入口)。 优先级: 1. Skill 自定义钩子(鸭子类型):``skill.viz_data_blocks(summary)`` → list[DataBlock] (需求 10.1/10.2)。无钩子则用底座默认适配。 2. 底座默认:descriptive_summary 的 ``groups`` + 通用 ``data_overview`` 时序。 所有数值为对 summary 的逐字拷贝,绝不取整/插值/换算(需求 17.3)。 """ summary = summary or {} hook = getattr(skill, "viz_data_blocks", None) if callable(hook): try: out = hook(summary) if isinstance(out, list) and all(isinstance(b, DataBlock) for b in out): return out except Exception: # noqa: BLE001 - 钩子异常回退底座默认,不崩溃 pass blocks: list[DataBlock] = [] if summary.get("groups"): blocks.extend(_descriptive_blocks(summary)) blocks.extend(_generic_timeseries_blocks(summary)) return blocks # =========================================================================== # 任务 3:Data_Shape_Classifier —— DataBlock → ShapeSignature # =========================================================================== def classify_block(block: "DataBlock") -> "ShapeSignature": """按字段存在性与基数判定数据形态签名(确定性,见设计 §3 判据表)。""" # 1) 时序:times 非空且某 series 数值与 times 等长、点数≥2。 times = block.times or [] if len(times) >= 2 and block.series: for s in block.series: vals = s.get("values") or [] if len(vals) == len(times): return ShapeSignature.TIME_SERIES # 2) 重复单位分布:units 非空、单位数≥3。 if block.units and len([v for v in block.units if _is_number(v)]) >= 3: return ShapeSignature.REPEATED_UNIT_DISTRIBUTION groups = block.groups or [] if groups: # 3) 跨属性合规:每项带 status/within_spec、且≥2 项(状态语义优先于数值)。 has_status = all(("status" in g or "within_spec" in g) for g in groups) has_value = all(_is_number(g.get("value")) for g in groups) if has_status and not has_value and len(groups) >= 1: return ShapeSignature.CROSS_ATTRIBUTE_COMPLIANCE # 4) 分组×单指标:每项含数值 value、分组数≥2。 if has_value and len(groups) >= 2: return ShapeSignature.GROUPED_SINGLE_METRIC # 5) 双变量:两 series 成对 (x, y)。 if block.series and len(block.series) == 2: xs = block.series[0].get("values") or [] ys = block.series[1].get("values") or [] if xs and len(xs) == len(ys): return ShapeSignature.BIVARIATE # 6) 单值对限度:单一数值 + 限度。 has_limit = (block.limit_low is not None or block.limit_high is not None or bool(block.limit_text)) single_val = (block.units and len([v for v in block.units if _is_number(v)]) == 1) or ( len(groups) == 1 and _is_number(groups[0].get("value"))) if has_limit and single_val: return ShapeSignature.SINGLE_VALUE_VS_LIMIT return ShapeSignature.UNKNOWN # =========================================================================== # 任务 4:形态→图型映射 + Eligibility_Gate(门槛与降级) # 任务 5:参考线来源约束(仅来自 compute 解析值) # =========================================================================== #: 出图门槛(下限)。 MIN_TREND_POINTS = 3 MIN_GROUPS = 2 MIN_UNITS = 3 #: 首期支持的图型(需求 13.1);范围外一律降级 TABLE。 _FIRST_PHASE_CHARTS = { ChartType.TREND_BAND, ChartType.GROUPED_BAR, ChartType.GROUPED_DOT, ChartType.DISTRIBUTION_DOT, ChartType.BOX_PLOT, ChartType.STATUS_MATRIX, } #: 形态 → 首选图型(确定性映射)。 _SHAPE_TO_CHART = { ShapeSignature.TIME_SERIES: ChartType.TREND_BAND, ShapeSignature.GROUPED_SINGLE_METRIC: ChartType.GROUPED_BAR, ShapeSignature.REPEATED_UNIT_DISTRIBUTION: ChartType.DISTRIBUTION_DOT, ShapeSignature.CROSS_ATTRIBUTE_COMPLIANCE: ChartType.STATUS_MATRIX, ShapeSignature.BIVARIATE: ChartType.SCATTER, ShapeSignature.SINGLE_VALUE_VS_LIMIT: ChartType.BULLET, ShapeSignature.UNKNOWN: ChartType.TABLE, } def _reference_lines_for(block: "DataBlock") -> list: """仅用 block 中来自 compute 的限度 / AV 数值构造参考线(需求 5/17)。 block 的 ``limit_low``/``limit_high``/``acceptance_value`` 均为 compute 逐字拷贝; 本函数不推导、不外推、不臆造任何数值。 """ lines: list = [] if block.limit_low is not None: lines.append({"value": block.limit_low, "label": f"下限 {block.limit_low:g}", "kind": "lower"}) if block.limit_high is not None: lines.append({"value": block.limit_high, "label": f"上限 {block.limit_high:g}", "kind": "upper"}) av = block.acceptance_value or {} if _is_number(av.get("limit")): lines.append({"value": av["limit"], "label": f"AV≤{av['limit']:g}", "kind": "av"}) return lines def _degrade(block: "DataBlock", intended: "ChartType", reason: str) -> "ChartSpec": return ChartSpec( chart_type=ChartType.TABLE, block_id=block.block_id, title=block.title, companion_table_block_id=block.block_id, degraded_from=intended, degrade_reason=reason, ) def map_block_to_spec( block: "DataBlock", sig: "ShapeSignature", *, first_phase: bool = True, ) -> "ChartSpec": """把 (block, 形态) 映射为 ChartSpec;不达门槛或超出首期范围则降级 TABLE。 返回的 ChartSpec 的 ``reference_lines`` 仅来自 compute 数值(需求 5/17); ``companion_table_block_id`` 指向自身,保证图表-源表配对(需求 14)。 """ intended = _SHAPE_TO_CHART.get(sig, ChartType.TABLE) # 出图门槛(Eligibility_Gate)。 if sig is ShapeSignature.TIME_SERIES: if len(block.times or []) < MIN_TREND_POINTS: return _degrade(block, intended, f"时间点不足{MIN_TREND_POINTS}") elif sig is ShapeSignature.GROUPED_SINGLE_METRIC: if len(block.groups or []) < MIN_GROUPS: return _degrade(block, intended, f"分组不足{MIN_GROUPS}") elif sig is ShapeSignature.REPEATED_UNIT_DISTRIBUTION: if len([v for v in (block.units or []) if _is_number(v)]) < MIN_UNITS: return _degrade(block, intended, f"单位不足{MIN_UNITS}") elif sig is ShapeSignature.CROSS_ATTRIBUTE_COMPLIANCE: levels = [g for g in (block.groups or []) if g.get("status") or g.get("within_spec") is not None] if not levels: return _degrade(block, intended, "无任何合规/风险等级") elif sig is ShapeSignature.SINGLE_VALUE_VS_LIMIT: if block.limit_low is None and block.limit_high is None and not block.limit_text: return _degrade(block, intended, "缺少限度,无法绘制子弹图") # 首期范围门禁(需求 13.2)。 if first_phase and intended not in _FIRST_PHASE_CHARTS: return _degrade(block, intended, "首期范围外图型") return ChartSpec( chart_type=intended, block_id=block.block_id, title=block.title, reference_lines=_reference_lines_for(block), risk_colored=(intended is ChartType.STATUS_MATRIX), redundant_marks=True, companion_table_block_id=block.block_id, ) # =========================================================================== # 任务 6:Intent_Weighting;任务 7:图表预算与聚合;任务 8:recommend 编排 # =========================================================================== #: 各意图下图型的优先级权重(值越大越靠前)。默认序兜底 unknown/缺失。 _INTENT_PRIORITY = { "descriptive_summary": { ChartType.STATUS_MATRIX: 100, ChartType.DISTRIBUTION_DOT: 90, ChartType.GROUPED_BAR: 80, ChartType.GROUPED_DOT: 80, ChartType.BOX_PLOT: 70, ChartType.TREND_BAND: 40, }, "shelf_life_extrapolation": { ChartType.TREND_BAND: 100, ChartType.GROUPED_BAR: 70, ChartType.GROUPED_DOT: 70, ChartType.STATUS_MATRIX: 60, ChartType.DISTRIBUTION_DOT: 50, }, "compatibility": { ChartType.STATUS_MATRIX: 100, ChartType.GROUPED_BAR: 70, ChartType.DISTRIBUTION_DOT: 60, ChartType.TREND_BAND: 50, }, } #: 默认(unknown/缺失)排序:时序 > 分布 > 分组 > 状态。 _DEFAULT_PRIORITY = { ChartType.TREND_BAND: 100, ChartType.DISTRIBUTION_DOT: 90, ChartType.BOX_PLOT: 85, ChartType.GROUPED_BAR: 80, ChartType.GROUPED_DOT: 80, ChartType.STATUS_MATRIX: 70, } def _priority_of(chart_type: "ChartType", intent: str) -> int: table = _INTENT_PRIORITY.get(str(intent or ""), _DEFAULT_PRIORITY) if chart_type is ChartType.TABLE: return -1 # 表格始终排在图表之后 return table.get(chart_type, _DEFAULT_PRIORITY.get(chart_type, 10)) class VisualizationRecommender: """图表推荐引擎:summary(+intent) → VisualizationPlan。 **纯 Python、确定性、不接收 svc/LLM 句柄、不 import matplotlib**。 """ def __init__(self, max_figures: int = 6) -> None: self.max_figures = int(max_figures) def recommend( self, summary: dict, *, skill_id: str = "", intent: str = "", skill: Any = None, existing_figures: Optional[dict] = None, # 预留:稳定性既有 figures(时序) ) -> "VisualizationPlan": notes: list[str] = [] blocks = build_data_blocks(summary, skill_id=skill_id, skill=skill) specs: list[ChartSpec] = [] for block in blocks: sig = classify_block(block) if sig is ShapeSignature.UNKNOWN: notes.append(f"{block.block_id}: 无法归类数据形态,降级表格") spec = map_block_to_spec(block, sig) # 聚合记录(需求 16.2):分组块记录其被合并的分组标签。 if block.groups and spec.chart_type in (ChartType.GROUPED_BAR, ChartType.GROUPED_DOT): spec.aggregated_from = [str(g.get("label", "")) for g in block.groups] if spec.degrade_reason: notes.append(f"{block.block_id}: 降级表格({spec.degrade_reason})") specs.append(spec) # 意图加权排序(稳定排序 → 幂等;只改顺序不改数据)。 order = list(enumerate(specs)) order.sort(key=lambda t: (-_priority_of(t[1].chart_type, intent), t[0])) specs = [s for _, s in order] # 图表预算(需求 16.1/16.4):超额的非表格图降级为表格。 kept = 0 for spec in specs: if spec.chart_type is ChartType.TABLE: continue if kept < self.max_figures: kept += 1 else: notes.append(f"{spec.block_id}: 超出图表预算({self.max_figures}),降级表格") spec.degraded_from = spec.chart_type spec.degrade_reason = f"超出图表预算({self.max_figures})" spec.chart_type = ChartType.TABLE return VisualizationPlan(specs=specs, notes=notes) # =========================================================================== # 任务 10 协同:把 VisualizationPlan 转为可渲染的 figures 数据 # =========================================================================== def figures_from_plan( summary: dict, plan: "VisualizationPlan", *, skill: Any = None, ) -> dict: """把 plan 中的非 TABLE 图表转为 ChartService 可渲染的 figures 数据条目。 数值来自重建的 DataBlock(与 recommend 时同一确定性来源,逐字一致)。返回 ``{block_id: {kind, ...data..., reference_lines, title, caption}}``;TABLE 类 spec 不产出 figure(由报告层以表格呈现)。 """ blocks = {b.block_id: b for b in build_data_blocks(summary, skill=skill)} figures: dict = {} for spec in plan.specs: if spec.chart_type is ChartType.TABLE: continue block = blocks.get(spec.block_id) if block is None: continue # 引用完整性兜底 ct = spec.chart_type common = { "title": spec.title or block.title, "caption": spec.caption_key or block.title, "reference_lines": [dict(r) for r in spec.reference_lines], } if ct is ChartType.DISTRIBUTION_DOT or ct is ChartType.BOX_PLOT: av = block.acceptance_value or {} figures[spec.block_id] = { "kind": "distribution_dot", "box": ct is ChartType.BOX_PLOT, "units": list(block.units or []), "mean": av.get("mean"), "value_label": block.attribute, **common, } elif ct in (ChartType.GROUPED_BAR, ChartType.GROUPED_DOT): labels = [str(g.get("label", "")) for g in (block.groups or [])] values = [g.get("value") for g in (block.groups or [])] figures[spec.block_id] = { "kind": "grouped_bar", "dot": ct is ChartType.GROUPED_DOT, "labels": labels, "values": values, "value_label": block.attribute, **common, } elif ct is ChartType.STATUS_MATRIX: rows = [{"label": str(g.get("label", "")), "status": str(g.get("status", "na"))} for g in (block.groups or [])] figures[spec.block_id] = {"kind": "status_matrix", "rows": rows, **common} elif ct is ChartType.TREND_BAND: series = block.series or [] traces = [{"times": list(block.times or []), "values": list(s.get("values") or []), "label": str(s.get("label", ""))} for s in series] figures[spec.block_id] = { "kind": "observed_trends", "traces": traces, "label": block.attribute, **common, } return figures