jupytermind 0.3.0
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.
- package/.github/skills/ai-chemistry-scientist/SKILL.md +97 -0
- package/.github/skills/ai-chemistry-scientist/manifest.json +156 -0
- package/.github/skills/ai-data-scientist/SKILL.md +330 -0
- package/.github/skills/ai-genomics-scientist/SKILL.md +98 -0
- package/.github/skills/ai-genomics-scientist/manifest.json +93 -0
- package/.github/skills/ai-materials-scientist/SKILL.md +51 -0
- package/.github/skills/ai-materials-scientist/manifest.json +58 -0
- package/.github/skills/ai-scientist/SKILL.md +69 -0
- package/.github/skills/ai-scientist/manifest.json +61 -0
- package/.github/skills/ai-structural-biology-scientist/SKILL.md +67 -0
- package/.github/skills/ai-structural-biology-scientist/manifest.json +72 -0
- package/.github/skills/japanese-prose/NOTICE.md +17 -0
- package/.github/skills/japanese-prose/SKILL.md +111 -0
- package/.github/skills/japanese-prose/references/review-workflow.md +50 -0
- package/.github/skills/japanese-prose/references/scoring.md +24 -0
- package/.github/skills/japanese-prose/references/writing-guidelines.md +60 -0
- package/.github/skills/japanese-prose/scripts/core.py +192 -0
- package/.github/skills/japanese-prose/scripts/fixtures/natural.md +5 -0
- package/.github/skills/japanese-prose/scripts/fixtures/unnatural.md +5 -0
- package/.github/skills/japanese-prose/scripts/lint.py +378 -0
- package/.github/skills/japanese-prose/scripts/outline.py +68 -0
- package/.github/skills/japanese-prose/scripts/terms.py +112 -0
- package/.github/skills/japanese-prose/scripts/test_engine.py +117 -0
- package/.github/skills/presentation-planner/SKILL.md +257 -0
- package/.github/skills/presentation-planner/assets/design-templates/data-report.yaml +97 -0
- package/.github/skills/presentation-planner/assets/design-templates/executive-proposal.yaml +92 -0
- package/.github/skills/presentation-planner/assets/design-templates/technical-briefing.yaml +96 -0
- package/.github/skills/presentation-planner/assets/scenario-templates/data-report.md +47 -0
- package/.github/skills/presentation-planner/assets/scenario-templates/executive-decision.md +43 -0
- package/.github/skills/presentation-planner/assets/scenario-templates/technical-briefing.md +45 -0
- package/.github/skills/presentation-planner/references/customizing-design-templates.md +160 -0
- package/.github/skills/presentation-planner/references/design-spec-schema.md +72 -0
- package/.github/skills/presentation-planner/references/handoff-contract.md +49 -0
- package/.github/skills/presentation-planner/references/responsibility-boundary.md +32 -0
- package/.github/skills/presentation-planner/references/scenario-templates.md +55 -0
- package/.github/skills/tech-writer/SKILL.md +434 -0
- package/.github/skills/tech-writer/assets/templates/blueprint.md +187 -0
- package/.github/skills/tech-writer/assets/templates/design-doc.md +29 -0
- package/.github/skills/tech-writer/assets/templates/migration-plan.md +173 -0
- package/.github/skills/tech-writer/assets/templates/operations-runbook.md +202 -0
- package/.github/skills/tech-writer/assets/templates/pr-description.md +23 -0
- package/.github/skills/tech-writer/assets/templates/qiita.md +44 -0
- package/.github/skills/tech-writer/assets/templates/readme.md +38 -0
- package/.github/skills/tech-writer/assets/templates/requirements-definition.md +170 -0
- package/.github/skills/tech-writer/assets/templates/rfi.md +113 -0
- package/.github/skills/tech-writer/assets/templates/rfp.md +180 -0
- package/.github/skills/tech-writer/assets/templates/security-design.md +167 -0
- package/.github/skills/tech-writer/assets/templates/system-design.md +220 -0
- package/.github/skills/tech-writer/assets/templates/technical-proposal.md +112 -0
- package/.github/skills/tech-writer/assets/templates/test-plan.md +153 -0
- package/.github/skills/tech-writer/assets/templates/user-manual.md +22 -0
- package/.github/skills/tech-writer/assets/templates/white-paper.md +192 -0
- package/.github/skills/tech-writer/references/doctypes/api-docs.md +33 -0
- package/.github/skills/tech-writer/references/doctypes/blueprint.md +81 -0
- package/.github/skills/tech-writer/references/doctypes/code-comments.md +39 -0
- package/.github/skills/tech-writer/references/doctypes/design-doc.md +42 -0
- package/.github/skills/tech-writer/references/doctypes/migration-plan.md +63 -0
- package/.github/skills/tech-writer/references/doctypes/operations-runbook.md +63 -0
- package/.github/skills/tech-writer/references/doctypes/pr-commit.md +82 -0
- package/.github/skills/tech-writer/references/doctypes/qiita.md +75 -0
- package/.github/skills/tech-writer/references/doctypes/readme.md +43 -0
- package/.github/skills/tech-writer/references/doctypes/release-notes.md +30 -0
- package/.github/skills/tech-writer/references/doctypes/requirements-definition.md +61 -0
- package/.github/skills/tech-writer/references/doctypes/rfi.md +43 -0
- package/.github/skills/tech-writer/references/doctypes/rfp.md +46 -0
- package/.github/skills/tech-writer/references/doctypes/security-design.md +71 -0
- package/.github/skills/tech-writer/references/doctypes/system-design.md +74 -0
- package/.github/skills/tech-writer/references/doctypes/technical-proposal.md +49 -0
- package/.github/skills/tech-writer/references/doctypes/test-plan.md +67 -0
- package/.github/skills/tech-writer/references/doctypes/user-manual.md +58 -0
- package/.github/skills/tech-writer/references/doctypes/white-paper.md +84 -0
- package/.github/skills/tech-writer/references/doctypes/zenn.md +66 -0
- package/.github/skills/tech-writer/references/japanese-prose-optimization.md +110 -0
- package/.github/skills/tech-writer/references/style-constitution.md +104 -0
- package/.github/skills/tech-writer/scripts/lint.py +412 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/ai-data-scientist.js +123 -0
- package/package.json +41 -0
- package/pyproject.toml +45 -0
- package/src/ai_chemistry_scientist/__init__.py +0 -0
- package/src/ai_chemistry_scientist/admet_prediction.py +71 -0
- package/src/ai_chemistry_scientist/bioactivity_classification.py +73 -0
- package/src/ai_chemistry_scientist/data/sample_molecules.csv +21 -0
- package/src/ai_chemistry_scientist/dispatch.py +369 -0
- package/src/ai_chemistry_scientist/docking_score.py +97 -0
- package/src/ai_chemistry_scientist/drug_likeness_rules.py +84 -0
- package/src/ai_chemistry_scientist/evidence.py +41 -0
- package/src/ai_chemistry_scientist/molecular_descriptors.py +97 -0
- package/src/ai_chemistry_scientist/molecular_formula_mass.py +40 -0
- package/src/ai_chemistry_scientist/molecular_similarity.py +78 -0
- package/src/ai_chemistry_scientist/qsar_modeling.py +105 -0
- package/src/ai_chemistry_scientist/salt_standardization.py +81 -0
- package/src/ai_chemistry_scientist/structural_alerts.py +76 -0
- package/src/ai_chemistry_scientist/structure_format_conversion.py +84 -0
- package/src/ai_chemistry_scientist/validation.py +70 -0
- package/src/ai_data_scientist/__init__.py +0 -0
- package/src/ai_data_scientist/analysis_assumptions.py +121 -0
- package/src/ai_data_scientist/anomaly_detection.py +39 -0
- package/src/ai_data_scientist/automl.py +109 -0
- package/src/ai_data_scientist/cleaning.py +56 -0
- package/src/ai_data_scientist/cli.py +90 -0
- package/src/ai_data_scientist/clustering.py +54 -0
- package/src/ai_data_scientist/dashboard.py +33 -0
- package/src/ai_data_scientist/data_definition.py +100 -0
- package/src/ai_data_scientist/data_quality.py +164 -0
- package/src/ai_data_scientist/dataset_validation.py +135 -0
- package/src/ai_data_scientist/dependency_pins.py +60 -0
- package/src/ai_data_scientist/eda.py +82 -0
- package/src/ai_data_scientist/experiment_evaluation.py +635 -0
- package/src/ai_data_scientist/explainability.py +340 -0
- package/src/ai_data_scientist/feature_engineering.py +163 -0
- package/src/ai_data_scientist/gate_config.py +32 -0
- package/src/ai_data_scientist/ingestion.py +127 -0
- package/src/ai_data_scientist/insight_engine.py +180 -0
- package/src/ai_data_scientist/japanese_nlp.py +43 -0
- package/src/ai_data_scientist/jupyter_launcher.py +137 -0
- package/src/ai_data_scientist/jupyter_mcp_client.py +94 -0
- package/src/ai_data_scientist/language_router.py +28 -0
- package/src/ai_data_scientist/lifecycle.py +221 -0
- package/src/ai_data_scientist/mcp_gateway.py +113 -0
- package/src/ai_data_scientist/mcp_runtime.py +194 -0
- package/src/ai_data_scientist/mcp_transport.py +53 -0
- package/src/ai_data_scientist/ml_modeling.py +451 -0
- package/src/ai_data_scientist/model_tuning.py +104 -0
- package/src/ai_data_scientist/notebook_audit.py +574 -0
- package/src/ai_data_scientist/project_manager.py +243 -0
- package/src/ai_data_scientist/report_export.py +73 -0
- package/src/ai_data_scientist/sensitivity.py +445 -0
- package/src/ai_data_scientist/signal_analysis.py +201 -0
- package/src/ai_data_scientist/skill_packaging.py +40 -0
- package/src/ai_data_scientist/stats_analysis.py +88 -0
- package/src/ai_data_scientist/text_nlp.py +44 -0
- package/src/ai_data_scientist/timeseries.py +68 -0
- package/src/ai_data_scientist/visualization.py +708 -0
- package/src/ai_genomics_scientist/__init__.py +1 -0
- package/src/ai_genomics_scientist/differential_expression.py +147 -0
- package/src/ai_genomics_scientist/dispatch.py +267 -0
- package/src/ai_genomics_scientist/evidence.py +45 -0
- package/src/ai_genomics_scientist/gene_set_enrichment.py +76 -0
- package/src/ai_genomics_scientist/sequence_alignment.py +97 -0
- package/src/ai_genomics_scientist/sequence_features.py +111 -0
- package/src/ai_genomics_scientist/splice_site_scoring.py +66 -0
- package/src/ai_genomics_scientist/validation.py +83 -0
- package/src/ai_genomics_scientist/variant_effect.py +147 -0
- package/src/ai_genomics_scientist/variant_pathogenicity.py +125 -0
- package/src/ai_materials_scientist/__init__.py +0 -0
- package/src/ai_materials_scientist/calphad.py +117 -0
- package/src/ai_materials_scientist/classical_monte_carlo.py +165 -0
- package/src/ai_materials_scientist/crystal_plasticity.py +184 -0
- package/src/ai_materials_scientist/dispatch.py +100 -0
- package/src/ai_materials_scientist/evidence.py +84 -0
- package/src/ai_materials_scientist/fem.py +279 -0
- package/src/ai_materials_scientist/kinetic_monte_carlo.py +145 -0
- package/src/ai_materials_scientist/molecular_dynamics.py +240 -0
- package/src/ai_materials_scientist/phase_field.py +167 -0
- package/src/ai_materials_scientist/validation.py +70 -0
- package/src/ai_scientist/__init__.py +1 -0
- package/src/ai_scientist/completion_gate.py +15 -0
- package/src/ai_scientist/data_analysis.py +46 -0
- package/src/ai_scientist/evidence_registry.py +99 -0
- package/src/ai_scientist/experimental_design.py +20 -0
- package/src/ai_scientist/language.py +14 -0
- package/src/ai_scientist/latex_renderer.py +41 -0
- package/src/ai_scientist/literature_review.py +37 -0
- package/src/ai_scientist/manifest.py +87 -0
- package/src/ai_scientist/manuscript.py +94 -0
- package/src/ai_scientist/mcp_config.py +76 -0
- package/src/ai_scientist/mcp_external.py +42 -0
- package/src/ai_scientist/mcp_failures.py +23 -0
- package/src/ai_scientist/mcp_gateway.py +38 -0
- package/src/ai_scientist/mcp_managed.py +180 -0
- package/src/ai_scientist/npm_packaging.py +49 -0
- package/src/ai_scientist/orchestrator.py +133 -0
- package/src/ai_scientist/peer_review.py +60 -0
- package/src/ai_scientist/phase_gate.py +74 -0
- package/src/ai_scientist/phase_state.py +230 -0
- package/src/ai_scientist/presentation.py +56 -0
- package/src/ai_scientist/project_config.py +31 -0
- package/src/ai_scientist/project_handle.py +74 -0
- package/src/ai_scientist/reproducibility.py +20 -0
- package/src/ai_scientist/research_planning.py +20 -0
- package/src/ai_scientist/skill_invocation.py +21 -0
- package/src/ai_scientist/tdd_gate.py +99 -0
- package/src/ai_structural_biology_scientist/__init__.py +0 -0
- package/src/ai_structural_biology_scientist/contact_map.py +87 -0
- package/src/ai_structural_biology_scientist/dispatch.py +269 -0
- package/src/ai_structural_biology_scientist/evidence.py +43 -0
- package/src/ai_structural_biology_scientist/hydrophobicity.py +101 -0
- package/src/ai_structural_biology_scientist/protein_docking_score.py +104 -0
- package/src/ai_structural_biology_scientist/secondary_structure.py +95 -0
- package/src/ai_structural_biology_scientist/structural_similarity.py +74 -0
- package/src/ai_structural_biology_scientist/validation.py +100 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Visualization generation.
|
|
2
|
+
|
|
3
|
+
Implements DES-AIDS-009 (REQ-AIDS-007): renders a requested chart to a PNG
|
|
4
|
+
image and packages it as an nbformat-compatible output MIME bundle.
|
|
5
|
+
CHANGE-012's DES-AIDS-073 through DES-AIDS-076 (CODE-AIDS-111 through
|
|
6
|
+
CODE-AIDS-118) add box/barh/heatmap kinds, hue-grouped series with legend
|
|
7
|
+
titles, symmetric/asymmetric error bars, and chart_metadata_from_figure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import io
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Self
|
|
19
|
+
|
|
20
|
+
import matplotlib
|
|
21
|
+
|
|
22
|
+
matplotlib.use("Agg")
|
|
23
|
+
import matplotlib.pyplot as plt
|
|
24
|
+
import nbformat
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from ai_data_scientist.project_manager import ProjectHandle, enqueue_write, next_execution_count
|
|
28
|
+
|
|
29
|
+
_SUPPORTED_KINDS = ("scatter", "line", "bar", "barh", "box", "hist", "heatmap")
|
|
30
|
+
_MISSING_GLYPH_RE = re.compile(r"Glyph (\d+) .* missing from (?:current )?font")
|
|
31
|
+
|
|
32
|
+
# @id CODE-AIDS-054
|
|
33
|
+
# @implements REQ-AIDS-046
|
|
34
|
+
# @design DES-AIDS-034
|
|
35
|
+
_japanese_font_applied = False
|
|
36
|
+
_JAPANESE_FONT_FAMILY = "IPAexGothic"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _contains_non_ascii(text: str | None) -> bool:
|
|
40
|
+
"""True if ``text`` contains a character matplotlib's default font
|
|
41
|
+
cannot render legibly (anything outside the printable ASCII range)."""
|
|
42
|
+
return bool(text) and any(ord(ch) > 127 for ch in text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_japanese_font() -> None:
|
|
46
|
+
"""Register (once) and reassert (every call) the bundled Japanese font.
|
|
47
|
+
|
|
48
|
+
Importing ``japanize_matplotlib`` registers its bundled IPAexGothic
|
|
49
|
+
TrueType font with matplotlib's font manager — an expensive, idempotent
|
|
50
|
+
side effect gated by ``_japanese_font_applied`` so it runs at most once
|
|
51
|
+
per process. Setting ``font.family`` to the registered font name is
|
|
52
|
+
cheap and, unlike the import, is *not* gated: it is reasserted on every
|
|
53
|
+
call that needs it (GitHub #32). Without this, a caller resetting
|
|
54
|
+
matplotlib's global ``rcParams`` between calls (e.g. ``plt.rcdefaults()``)
|
|
55
|
+
would silently revert ``font.family`` to its default, and the previous
|
|
56
|
+
once-per-process guard would then skip reapplying it, causing Japanese
|
|
57
|
+
text to render as "tofu" boxes on later calls even though the font was
|
|
58
|
+
already registered.
|
|
59
|
+
"""
|
|
60
|
+
global _japanese_font_applied
|
|
61
|
+
if not _japanese_font_applied:
|
|
62
|
+
with warnings.catch_warnings():
|
|
63
|
+
# japanize_matplotlib relies on distutils.version.LooseVersion,
|
|
64
|
+
# which emits a harmless DeprecationWarning under modern setuptools.
|
|
65
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
66
|
+
import japanize_matplotlib # noqa: F401 - import side effect registers the font
|
|
67
|
+
_japanese_font_applied = True
|
|
68
|
+
matplotlib.rcParams["font.family"] = _JAPANESE_FONT_FAMILY
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# @id CODE-AIDS-084
|
|
72
|
+
# @implements REQ-AIDS-064
|
|
73
|
+
# @design DES-AIDS-052
|
|
74
|
+
_JAPANESE_CHAR_RE = re.compile(
|
|
75
|
+
"["
|
|
76
|
+
"\u3040-\u309f" # Hiragana
|
|
77
|
+
"\u30a0-\u30ff" # Katakana
|
|
78
|
+
"\u31f0-\u31ff" # Katakana Phonetic Extensions
|
|
79
|
+
"\uff65-\uff9f" # Halfwidth Katakana
|
|
80
|
+
"\u3000-\u303f" # CJK Symbols and Punctuation
|
|
81
|
+
"\u4e00-\u9fff" # CJK Unified Ideographs
|
|
82
|
+
"\u3400-\u4dbf" # CJK Unified Ideographs Extension A
|
|
83
|
+
"\uf900-\ufaff" # CJK Compatibility Ideographs
|
|
84
|
+
"]"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _contains_japanese(text) -> bool:
|
|
89
|
+
"""True if ``str(text)`` contains a Japanese-derived character.
|
|
90
|
+
|
|
91
|
+
Narrower than ``_contains_non_ascii``: only matches Hiragana, Katakana
|
|
92
|
+
(including phonetic extensions and halfwidth forms), CJK symbols and
|
|
93
|
+
punctuation, and CJK (Unified/Extension-A/Compatibility) ideographs, per
|
|
94
|
+
REQ-AIDS-064's scoping to Japanese-derived plotted data/tick/legend text.
|
|
95
|
+
Non-string, non-stringifiable values are treated as not containing
|
|
96
|
+
Japanese text.
|
|
97
|
+
"""
|
|
98
|
+
if text is None:
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
value = str(text)
|
|
102
|
+
except Exception: # noqa: BLE001 - arbitrary user objects may raise from __str__
|
|
103
|
+
return False
|
|
104
|
+
return bool(_JAPANESE_CHAR_RE.search(value))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _plotted_data_contains_japanese(
|
|
108
|
+
df: pd.DataFrame,
|
|
109
|
+
kind: str,
|
|
110
|
+
x: str | None,
|
|
111
|
+
y: str | None,
|
|
112
|
+
hue: str | None = None,
|
|
113
|
+
legend_title: str | None = None,
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""True if any plotted value/index/legend-source/auto-axis-label text
|
|
116
|
+
for this ``render_chart`` call contains Japanese characters (DES-AIDS-052).
|
|
117
|
+
"""
|
|
118
|
+
candidates: list = [legend_title]
|
|
119
|
+
|
|
120
|
+
def _add_column(column_name: str | None) -> None:
|
|
121
|
+
if column_name is None:
|
|
122
|
+
return
|
|
123
|
+
candidates.append(column_name)
|
|
124
|
+
if column_name in df.columns:
|
|
125
|
+
candidates.extend(df[column_name])
|
|
126
|
+
|
|
127
|
+
_add_column(hue)
|
|
128
|
+
|
|
129
|
+
if kind == "hist" and x is not None:
|
|
130
|
+
_add_column(x)
|
|
131
|
+
candidates.append(df.index.name)
|
|
132
|
+
candidates.extend(df.index)
|
|
133
|
+
elif kind == "heatmap":
|
|
134
|
+
if x is not None and y is not None:
|
|
135
|
+
_add_column(x)
|
|
136
|
+
_add_column(y)
|
|
137
|
+
else:
|
|
138
|
+
candidates.extend(df.columns)
|
|
139
|
+
candidates.append(df.index.name)
|
|
140
|
+
candidates.extend(df.index)
|
|
141
|
+
elif kind == "box":
|
|
142
|
+
_add_column(x)
|
|
143
|
+
_add_column(y)
|
|
144
|
+
if x is None and y is None:
|
|
145
|
+
candidates.extend(df.columns)
|
|
146
|
+
else:
|
|
147
|
+
if x is not None:
|
|
148
|
+
_add_column(x)
|
|
149
|
+
else:
|
|
150
|
+
candidates.append(df.index.name)
|
|
151
|
+
candidates.extend(df.index)
|
|
152
|
+
|
|
153
|
+
if y is not None:
|
|
154
|
+
_add_column(y)
|
|
155
|
+
else:
|
|
156
|
+
implicit_columns = [column for column in df.columns if column not in {x, hue}]
|
|
157
|
+
for column in implicit_columns:
|
|
158
|
+
_add_column(column)
|
|
159
|
+
|
|
160
|
+
return any(_contains_japanese(candidate) for candidate in candidates)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# @id CODE-AIDS-085
|
|
164
|
+
# @implements REQ-AIDS-060
|
|
165
|
+
# @design DES-AIDS-048
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ChartMetadata:
|
|
168
|
+
"""Chart authoring metadata captured at render time (REQ-AIDS-060)."""
|
|
169
|
+
|
|
170
|
+
title: str | None
|
|
171
|
+
xlabel: str | None
|
|
172
|
+
ylabel: str | None
|
|
173
|
+
legend: bool
|
|
174
|
+
legend_title: str | None = None
|
|
175
|
+
missing_glyphs: tuple[str, ...] = field(default_factory=tuple)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RenderedChart(bytes):
|
|
179
|
+
"""PNG bytes returned by ``render_chart``, carrying ``chart_metadata``.
|
|
180
|
+
|
|
181
|
+
A real ``bytes`` subclass so every existing caller that treats the
|
|
182
|
+
return value via ordinary ``bytes`` operations (``base64.b64encode``,
|
|
183
|
+
equality, slicing, hashing) continues to work unmodified (REQ-AIDS-060).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
chart_metadata: ChartMetadata
|
|
187
|
+
|
|
188
|
+
def __new__(cls, data: bytes, chart_metadata: ChartMetadata) -> Self:
|
|
189
|
+
instance = super().__new__(cls, data)
|
|
190
|
+
instance.chart_metadata = chart_metadata
|
|
191
|
+
return instance
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def title(self) -> str | None:
|
|
195
|
+
return self.chart_metadata.title
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def xlabel(self) -> str | None:
|
|
199
|
+
return self.chart_metadata.xlabel
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def ylabel(self) -> str | None:
|
|
203
|
+
return self.chart_metadata.ylabel
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def legend(self) -> bool:
|
|
207
|
+
return self.chart_metadata.legend
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def legend_title(self) -> str | None:
|
|
211
|
+
return self.chart_metadata.legend_title
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def missing_glyphs(self) -> tuple[str, ...]:
|
|
215
|
+
return self.chart_metadata.missing_glyphs
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# @id CODE-AIDS-111
|
|
219
|
+
# @implements REQ-AIDS-088
|
|
220
|
+
# @design DES-AIDS-076
|
|
221
|
+
def chart_metadata_from_figure(
|
|
222
|
+
fig, *, missing_glyphs: tuple[str, ...] | list[str] = ()
|
|
223
|
+
) -> ChartMetadata:
|
|
224
|
+
"""Build ``ChartMetadata`` from an existing matplotlib ``Figure``."""
|
|
225
|
+
if not fig.axes:
|
|
226
|
+
raise ValueError("Figure has no axes to inspect")
|
|
227
|
+
ax = fig.axes[0]
|
|
228
|
+
legend = ax.get_legend()
|
|
229
|
+
legend_title = None
|
|
230
|
+
if legend is not None:
|
|
231
|
+
legend_title = legend.get_title().get_text() or None
|
|
232
|
+
return ChartMetadata(
|
|
233
|
+
title=ax.get_title() or None,
|
|
234
|
+
xlabel=ax.get_xlabel() or None,
|
|
235
|
+
ylabel=ax.get_ylabel() or None,
|
|
236
|
+
legend=legend is not None,
|
|
237
|
+
legend_title=legend_title,
|
|
238
|
+
missing_glyphs=tuple(str(codepoint) for codepoint in missing_glyphs),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _apply_legend_title(ax, hue: str | None, legend_title: str | None) -> None:
|
|
243
|
+
legend = ax.get_legend()
|
|
244
|
+
if legend is None:
|
|
245
|
+
return
|
|
246
|
+
if legend_title is not None:
|
|
247
|
+
legend.set_title(legend_title)
|
|
248
|
+
elif hue is not None:
|
|
249
|
+
legend.set_title(hue)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _require_columns(df: pd.DataFrame, *columns: str | None) -> None:
|
|
253
|
+
for column in columns:
|
|
254
|
+
if column is not None and column not in df.columns:
|
|
255
|
+
raise ValueError(f"Unknown column: {column!r}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# @id CODE-AIDS-112
|
|
259
|
+
# @implements REQ-AIDS-087
|
|
260
|
+
# @design DES-AIDS-075
|
|
261
|
+
def _resolve_error_values(
|
|
262
|
+
df: pd.DataFrame, spec: str | tuple[str, str] | list[str] | None
|
|
263
|
+
) -> list[float] | list[list[float]] | None:
|
|
264
|
+
if spec is None:
|
|
265
|
+
return None
|
|
266
|
+
if isinstance(spec, str):
|
|
267
|
+
_require_columns(df, spec)
|
|
268
|
+
return df[spec].tolist()
|
|
269
|
+
if (
|
|
270
|
+
isinstance(spec, (tuple, list))
|
|
271
|
+
and len(spec) == 2
|
|
272
|
+
and all(isinstance(column, str) for column in spec)
|
|
273
|
+
):
|
|
274
|
+
lower, upper = spec
|
|
275
|
+
_require_columns(df, lower, upper)
|
|
276
|
+
return [df[lower].tolist(), df[upper].tolist()]
|
|
277
|
+
raise ValueError("Error ranges must be a column name or a two-column (lower, upper) pair")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _group_label(value) -> str:
|
|
281
|
+
return "NaN" if pd.isna(value) else str(value)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# @id CODE-AIDS-116
|
|
285
|
+
# @implements REQ-AIDS-086
|
|
286
|
+
# @design DES-AIDS-074
|
|
287
|
+
def _normalize_hue_for_pivot(series: pd.Series) -> tuple[pd.Series, str]:
|
|
288
|
+
sentinel = f"__missing_hue__{secrets.token_hex(8)}"
|
|
289
|
+
existing_values = {str(value) for value in series.dropna().tolist()}
|
|
290
|
+
while sentinel in existing_values:
|
|
291
|
+
sentinel = f"__missing_hue__{secrets.token_hex(8)}"
|
|
292
|
+
normalized = series.astype("object").where(~series.isna(), sentinel)
|
|
293
|
+
return normalized, sentinel
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _pivot_grouped_values(df: pd.DataFrame, *, x: str, hue: str, value: str) -> pd.DataFrame:
|
|
297
|
+
normalized_hue, sentinel = _normalize_hue_for_pivot(df[hue])
|
|
298
|
+
order = list(dict.fromkeys(normalized_hue.tolist()))
|
|
299
|
+
pivot_source = pd.DataFrame({x: df[x], "__hue__": normalized_hue, value: df[value]})
|
|
300
|
+
duplicates = pivot_source.duplicated(subset=[x, "__hue__"], keep=False)
|
|
301
|
+
if duplicates.any():
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"Grouped {value!r} data must be unique per ({x!r}, {hue!r}) pair; "
|
|
304
|
+
"pre-aggregate duplicate rows before plotting."
|
|
305
|
+
)
|
|
306
|
+
pivot = pivot_source.pivot(index=x, columns="__hue__", values=value)
|
|
307
|
+
pivot = pivot.reindex(columns=order)
|
|
308
|
+
pivot = pivot.rename(columns={sentinel: "NaN"})
|
|
309
|
+
return pivot
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# @id CODE-AIDS-117
|
|
313
|
+
# @implements REQ-AIDS-087
|
|
314
|
+
# @design DES-AIDS-075
|
|
315
|
+
def _pivot_error_values(
|
|
316
|
+
df: pd.DataFrame,
|
|
317
|
+
*,
|
|
318
|
+
x: str,
|
|
319
|
+
hue: str,
|
|
320
|
+
spec: str | tuple[str, str] | list[str] | None,
|
|
321
|
+
):
|
|
322
|
+
if spec is None:
|
|
323
|
+
return None
|
|
324
|
+
if isinstance(spec, str):
|
|
325
|
+
return _pivot_grouped_values(df, x=x, hue=hue, value=spec)
|
|
326
|
+
if isinstance(spec, (tuple, list)) and len(spec) == 2:
|
|
327
|
+
lower, upper = spec
|
|
328
|
+
return [
|
|
329
|
+
_pivot_grouped_values(df, x=x, hue=hue, value=lower),
|
|
330
|
+
_pivot_grouped_values(df, x=x, hue=hue, value=upper),
|
|
331
|
+
]
|
|
332
|
+
raise ValueError("Error ranges must be a column name or a two-column (lower, upper) pair")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# @id CODE-AIDS-113
|
|
336
|
+
# @implements REQ-AIDS-086
|
|
337
|
+
# @design DES-AIDS-074
|
|
338
|
+
def _plot_with_hue(
|
|
339
|
+
df: pd.DataFrame,
|
|
340
|
+
*,
|
|
341
|
+
kind: str,
|
|
342
|
+
x: str | None,
|
|
343
|
+
y: str | None,
|
|
344
|
+
hue: str,
|
|
345
|
+
ax,
|
|
346
|
+
xerr: list[float] | list[list[float]] | None,
|
|
347
|
+
yerr: list[float] | list[list[float]] | None,
|
|
348
|
+
) -> None:
|
|
349
|
+
_require_columns(df, hue)
|
|
350
|
+
if kind == "scatter":
|
|
351
|
+
_require_columns(df, x, y)
|
|
352
|
+
for label, group in df.groupby(hue, sort=False, dropna=False):
|
|
353
|
+
ax.errorbar(
|
|
354
|
+
group[x],
|
|
355
|
+
group[y],
|
|
356
|
+
xerr=_resolve_error_values(group, xerr),
|
|
357
|
+
yerr=_resolve_error_values(group, yerr),
|
|
358
|
+
fmt="o",
|
|
359
|
+
linestyle="none",
|
|
360
|
+
label=_group_label(label),
|
|
361
|
+
)
|
|
362
|
+
ax.legend()
|
|
363
|
+
elif kind == "line":
|
|
364
|
+
_require_columns(df, y)
|
|
365
|
+
if y is None:
|
|
366
|
+
raise ValueError("Line charts with hue require a y column")
|
|
367
|
+
for label, group in df.groupby(hue, sort=False, dropna=False):
|
|
368
|
+
x_values = group[x] if x is not None else group.index
|
|
369
|
+
ax.errorbar(
|
|
370
|
+
x_values,
|
|
371
|
+
group[y],
|
|
372
|
+
xerr=_resolve_error_values(group, xerr),
|
|
373
|
+
yerr=_resolve_error_values(group, yerr),
|
|
374
|
+
fmt="-",
|
|
375
|
+
label=_group_label(label),
|
|
376
|
+
)
|
|
377
|
+
ax.legend()
|
|
378
|
+
if x is not None:
|
|
379
|
+
ax.set_xlabel(x)
|
|
380
|
+
if y is not None:
|
|
381
|
+
ax.set_ylabel(y)
|
|
382
|
+
elif kind == "hist":
|
|
383
|
+
_require_columns(df, x)
|
|
384
|
+
for label, group in df.groupby(hue, sort=False, dropna=False):
|
|
385
|
+
ax.hist(group[x].dropna(), label=_group_label(label), alpha=0.7)
|
|
386
|
+
ax.legend()
|
|
387
|
+
elif kind in {"bar", "barh"}:
|
|
388
|
+
_require_columns(df, x, y)
|
|
389
|
+
pivot = _pivot_grouped_values(df, x=x, hue=hue, value=y)
|
|
390
|
+
kwargs = {}
|
|
391
|
+
if xerr is not None:
|
|
392
|
+
kwargs["xerr"] = _pivot_error_values(df, x=x, hue=hue, spec=xerr)
|
|
393
|
+
if yerr is not None:
|
|
394
|
+
kwargs["yerr"] = _pivot_error_values(df, x=x, hue=hue, spec=yerr)
|
|
395
|
+
pivot.plot(kind=kind, ax=ax, **kwargs)
|
|
396
|
+
else:
|
|
397
|
+
raise ValueError(f"hue is not supported for chart kind: {kind!r}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# @id CODE-AIDS-114
|
|
401
|
+
# @implements REQ-AIDS-085
|
|
402
|
+
# @design DES-AIDS-073
|
|
403
|
+
def _plot_box_chart(df: pd.DataFrame, *, x: str | None, y: str | None, ax) -> None:
|
|
404
|
+
if y is None:
|
|
405
|
+
numeric = df.select_dtypes(include="number")
|
|
406
|
+
if numeric.empty:
|
|
407
|
+
raise ValueError("Box charts require at least one numeric column")
|
|
408
|
+
values = [numeric[column].dropna().tolist() for column in numeric.columns]
|
|
409
|
+
labels = [str(column) for column in numeric.columns]
|
|
410
|
+
elif x is None:
|
|
411
|
+
_require_columns(df, y)
|
|
412
|
+
values = [df[y].dropna().tolist()]
|
|
413
|
+
labels = [str(y)]
|
|
414
|
+
ax.set_ylabel(y)
|
|
415
|
+
else:
|
|
416
|
+
_require_columns(df, x, y)
|
|
417
|
+
values = []
|
|
418
|
+
labels = []
|
|
419
|
+
for label, group in df.groupby(x, sort=False, dropna=False):
|
|
420
|
+
points = group[y].dropna().tolist()
|
|
421
|
+
if not points:
|
|
422
|
+
continue
|
|
423
|
+
values.append(points)
|
|
424
|
+
labels.append(_group_label(label))
|
|
425
|
+
if not values:
|
|
426
|
+
raise ValueError("Box charts require at least one non-empty group")
|
|
427
|
+
ax.set_xlabel(x)
|
|
428
|
+
ax.set_ylabel(y)
|
|
429
|
+
|
|
430
|
+
ax.boxplot(values)
|
|
431
|
+
ax.set_xticks(range(1, len(labels) + 1))
|
|
432
|
+
ax.set_xticklabels(labels)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# @id CODE-AIDS-115
|
|
436
|
+
# @implements REQ-AIDS-085
|
|
437
|
+
# @design DES-AIDS-073
|
|
438
|
+
def _plot_heatmap(df: pd.DataFrame, *, x: str | None, y: str | None, ax) -> None:
|
|
439
|
+
if x is not None and y is not None:
|
|
440
|
+
_require_columns(df, x, y)
|
|
441
|
+
matrix = df[[x, y]].select_dtypes(include="number").corr()
|
|
442
|
+
else:
|
|
443
|
+
numeric = df.select_dtypes(include="number")
|
|
444
|
+
if (
|
|
445
|
+
not numeric.empty
|
|
446
|
+
and numeric.shape[0] == numeric.shape[1]
|
|
447
|
+
and list(map(str, numeric.index)) == list(map(str, numeric.columns))
|
|
448
|
+
):
|
|
449
|
+
matrix = numeric
|
|
450
|
+
else:
|
|
451
|
+
matrix = numeric.corr()
|
|
452
|
+
if matrix.empty:
|
|
453
|
+
raise ValueError("Heatmap charts require numeric data")
|
|
454
|
+
image = ax.imshow(matrix.to_numpy(), aspect="auto")
|
|
455
|
+
ax.set_xticks(range(len(matrix.columns)))
|
|
456
|
+
ax.set_xticklabels([str(column) for column in matrix.columns])
|
|
457
|
+
ax.set_yticks(range(len(matrix.index)))
|
|
458
|
+
ax.set_yticklabels([str(index) for index in matrix.index])
|
|
459
|
+
fig = ax.figure
|
|
460
|
+
fig.colorbar(image, ax=ax)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _plot_with_existing_paths(
|
|
464
|
+
df: pd.DataFrame,
|
|
465
|
+
*,
|
|
466
|
+
kind: str,
|
|
467
|
+
x: str | None,
|
|
468
|
+
y: str | None,
|
|
469
|
+
ax,
|
|
470
|
+
xerr: list[float] | list[list[float]] | None,
|
|
471
|
+
yerr: list[float] | list[list[float]] | None,
|
|
472
|
+
) -> None:
|
|
473
|
+
if kind == "hist":
|
|
474
|
+
if xerr is not None or yerr is not None:
|
|
475
|
+
raise ValueError("Histogram charts do not support error ranges")
|
|
476
|
+
_require_columns(df, x)
|
|
477
|
+
df[x].plot(kind="hist", ax=ax)
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
if kind == "scatter":
|
|
481
|
+
if xerr is None and yerr is None:
|
|
482
|
+
df.plot(kind="scatter", x=x, y=y, ax=ax)
|
|
483
|
+
return
|
|
484
|
+
_require_columns(df, x, y)
|
|
485
|
+
ax.errorbar(df[x], df[y], xerr=xerr, yerr=yerr, fmt="o", linestyle="none")
|
|
486
|
+
ax.set_xlabel(x)
|
|
487
|
+
ax.set_ylabel(y)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
if kind == "line":
|
|
491
|
+
if xerr is None and yerr is None:
|
|
492
|
+
df.plot(kind="line", x=x, y=y, ax=ax)
|
|
493
|
+
return
|
|
494
|
+
if y is None:
|
|
495
|
+
raise ValueError("Line charts require y when error ranges are requested")
|
|
496
|
+
x_values = df[x] if x is not None else df.index
|
|
497
|
+
ax.errorbar(x_values, df[y], xerr=xerr, yerr=yerr, fmt="-")
|
|
498
|
+
if x is not None:
|
|
499
|
+
ax.set_xlabel(x)
|
|
500
|
+
ax.set_ylabel(y)
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
kwargs = {}
|
|
504
|
+
if xerr is not None:
|
|
505
|
+
kwargs["xerr"] = xerr
|
|
506
|
+
if yerr is not None:
|
|
507
|
+
kwargs["yerr"] = yerr
|
|
508
|
+
df.plot(kind=kind, x=x, y=y, ax=ax, **kwargs)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# @id CODE-AIDS-007
|
|
512
|
+
# @implements REQ-AIDS-007 REQ-AIDS-058 REQ-AIDS-060 REQ-AIDS-064
|
|
513
|
+
# @design DES-AIDS-009 DES-AIDS-048 DES-AIDS-052
|
|
514
|
+
# @id CODE-AIDS-118
|
|
515
|
+
# @implements REQ-AIDS-085 REQ-AIDS-086 REQ-AIDS-087
|
|
516
|
+
# @design DES-AIDS-073 DES-AIDS-074 DES-AIDS-075
|
|
517
|
+
# CHANGE-004: returns RenderedChart (chart metadata) and widens the
|
|
518
|
+
# bundled-Japanese-font trigger; see the render_chart docstring for detail.
|
|
519
|
+
def render_chart(
|
|
520
|
+
df: pd.DataFrame,
|
|
521
|
+
kind: str = "scatter",
|
|
522
|
+
x: str | None = None,
|
|
523
|
+
y: str | None = None,
|
|
524
|
+
title: str | None = None,
|
|
525
|
+
xlabel: str | None = None,
|
|
526
|
+
ylabel: str | None = None,
|
|
527
|
+
hue: str | None = None,
|
|
528
|
+
legend_title: str | None = None,
|
|
529
|
+
xerr: str | tuple[str, str] | list[str] | None = None,
|
|
530
|
+
yerr: str | tuple[str, str] | list[str] | None = None,
|
|
531
|
+
) -> RenderedChart:
|
|
532
|
+
"""Render ``df`` as ``kind`` chart and return a ``RenderedChart``.
|
|
533
|
+
|
|
534
|
+
``title``/``xlabel``/``ylabel`` containing non-ASCII characters (e.g.
|
|
535
|
+
Japanese), or plotted data/tick labels/legend/pandas-auto-generated axis
|
|
536
|
+
labels containing Japanese characters, are rendered using a bundled
|
|
537
|
+
Japanese-capable font (REQ-AIDS-046, REQ-AIDS-064) so they display as
|
|
538
|
+
legible glyphs instead of matplotlib default placeholder boxes,
|
|
539
|
+
regardless of fonts installed on the host. The returned ``RenderedChart``
|
|
540
|
+
is a ``bytes`` subclass carrying the actually-rendered title, axis
|
|
541
|
+
labels, legend presence, and any missing-glyph warnings (REQ-AIDS-060),
|
|
542
|
+
so every existing caller using it as plain PNG bytes continues to work
|
|
543
|
+
unmodified.
|
|
544
|
+
"""
|
|
545
|
+
if kind not in _SUPPORTED_KINDS:
|
|
546
|
+
raise ValueError(f"Unsupported chart kind: {kind!r}")
|
|
547
|
+
|
|
548
|
+
if any(_contains_non_ascii(text) for text in (title, xlabel, ylabel)) or (
|
|
549
|
+
_plotted_data_contains_japanese(df, kind, x, y, hue=hue, legend_title=legend_title)
|
|
550
|
+
):
|
|
551
|
+
_ensure_japanese_font()
|
|
552
|
+
|
|
553
|
+
fig, ax = plt.subplots()
|
|
554
|
+
try:
|
|
555
|
+
with warnings.catch_warnings(record=True) as captured:
|
|
556
|
+
warnings.simplefilter("always")
|
|
557
|
+
resolved_xerr = _resolve_error_values(df, xerr)
|
|
558
|
+
resolved_yerr = _resolve_error_values(df, yerr)
|
|
559
|
+
if kind == "box":
|
|
560
|
+
_plot_box_chart(df, x=x, y=y, ax=ax)
|
|
561
|
+
elif kind == "heatmap":
|
|
562
|
+
_plot_heatmap(df, x=x, y=y, ax=ax)
|
|
563
|
+
elif hue is not None:
|
|
564
|
+
_plot_with_hue(
|
|
565
|
+
df,
|
|
566
|
+
kind=kind,
|
|
567
|
+
x=x,
|
|
568
|
+
y=y,
|
|
569
|
+
hue=hue,
|
|
570
|
+
ax=ax,
|
|
571
|
+
xerr=xerr,
|
|
572
|
+
yerr=yerr,
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
_plot_with_existing_paths(
|
|
576
|
+
df,
|
|
577
|
+
kind=kind,
|
|
578
|
+
x=x,
|
|
579
|
+
y=y,
|
|
580
|
+
ax=ax,
|
|
581
|
+
xerr=resolved_xerr,
|
|
582
|
+
yerr=resolved_yerr,
|
|
583
|
+
)
|
|
584
|
+
_apply_legend_title(ax, hue=hue, legend_title=legend_title)
|
|
585
|
+
if title is not None:
|
|
586
|
+
ax.set_title(title)
|
|
587
|
+
if xlabel is not None:
|
|
588
|
+
ax.set_xlabel(xlabel)
|
|
589
|
+
if ylabel is not None:
|
|
590
|
+
ax.set_ylabel(ylabel)
|
|
591
|
+
# GitHub #31: reposition/pad title, axis labels, and tick labels
|
|
592
|
+
# to fit within the saved canvas instead of being clipped.
|
|
593
|
+
# Applied on every call (not gated by a label-length heuristic)
|
|
594
|
+
# so behavior is deterministic. Runs after text is set
|
|
595
|
+
# (tight_layout measures already-rendered text metrics) and
|
|
596
|
+
# after font configuration above.
|
|
597
|
+
try:
|
|
598
|
+
fig.tight_layout()
|
|
599
|
+
except Exception: # noqa: BLE001 - backend/projection-specific matplotlib failure
|
|
600
|
+
# Some axes projections (e.g. 3D) raise from tight_layout();
|
|
601
|
+
# constrained_layout is a safe fallback that still
|
|
602
|
+
# repositions text to avoid clipping.
|
|
603
|
+
fig.set_layout_engine("constrained")
|
|
604
|
+
buffer = io.BytesIO()
|
|
605
|
+
fig.savefig(buffer, format="png", bbox_inches="tight")
|
|
606
|
+
|
|
607
|
+
missing_glyphs: list[str] = []
|
|
608
|
+
for captured_warning in captured:
|
|
609
|
+
match = _MISSING_GLYPH_RE.search(str(captured_warning.message))
|
|
610
|
+
if match:
|
|
611
|
+
codepoint = match.group(1)
|
|
612
|
+
if codepoint not in missing_glyphs:
|
|
613
|
+
missing_glyphs.append(codepoint)
|
|
614
|
+
else:
|
|
615
|
+
warnings.warn_explicit(
|
|
616
|
+
captured_warning.message,
|
|
617
|
+
captured_warning.category,
|
|
618
|
+
captured_warning.filename,
|
|
619
|
+
captured_warning.lineno,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
metadata = chart_metadata_from_figure(fig, missing_glyphs=tuple(missing_glyphs))
|
|
623
|
+
return RenderedChart(buffer.getvalue(), chart_metadata=metadata)
|
|
624
|
+
finally:
|
|
625
|
+
plt.close(fig)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# @id CODE-AIDS-091
|
|
629
|
+
# @implements REQ-AIDS-071
|
|
630
|
+
# @design DES-AIDS-059
|
|
631
|
+
def _chart_metadata_dict(chart_metadata: ChartMetadata) -> dict:
|
|
632
|
+
"""Build the JSON-serializable metadata dict shared by
|
|
633
|
+
``build_image_output`` (output-level) and ``record_chart`` (cell-level)
|
|
634
|
+
for a rendered chart's authoring metadata (DES-AIDS-059)."""
|
|
635
|
+
return {
|
|
636
|
+
"title": chart_metadata.title,
|
|
637
|
+
"xlabel": chart_metadata.xlabel,
|
|
638
|
+
"ylabel": chart_metadata.ylabel,
|
|
639
|
+
"legend": chart_metadata.legend,
|
|
640
|
+
"legend_title": chart_metadata.legend_title,
|
|
641
|
+
"missing_glyphs": list(chart_metadata.missing_glyphs),
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# @id CODE-AIDS-034
|
|
646
|
+
# @implements REQ-AIDS-007 REQ-AIDS-071
|
|
647
|
+
# @design DES-AIDS-009 DES-AIDS-059
|
|
648
|
+
def build_image_output(png_bytes: bytes) -> nbformat.NotebookNode:
|
|
649
|
+
"""Wrap PNG bytes as an nbformat execute_result output with image/png data.
|
|
650
|
+
|
|
651
|
+
When ``png_bytes`` is a ``RenderedChart`` (the result of
|
|
652
|
+
``render_chart``), its ``chart_metadata`` is additionally persisted into
|
|
653
|
+
the output's own ``metadata["chart"]`` mapping (REQ-AIDS-071), so a
|
|
654
|
+
single image output is independently auditable even outside
|
|
655
|
+
``record_chart``'s cell-level metadata. Plain ``bytes`` leave
|
|
656
|
+
``metadata`` empty, preserving prior behavior.
|
|
657
|
+
"""
|
|
658
|
+
encoded = base64.b64encode(png_bytes).decode("ascii")
|
|
659
|
+
metadata = {}
|
|
660
|
+
if isinstance(png_bytes, RenderedChart):
|
|
661
|
+
metadata["chart"] = _chart_metadata_dict(png_bytes.chart_metadata)
|
|
662
|
+
return nbformat.v4.new_output(
|
|
663
|
+
"execute_result",
|
|
664
|
+
data={"image/png": encoded, "text/plain": "<matplotlib chart>"},
|
|
665
|
+
metadata=metadata,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# @id CODE-AIDS-035
|
|
670
|
+
# @implements REQ-AIDS-007 REQ-AIDS-040 REQ-AIDS-061
|
|
671
|
+
# @design DES-AIDS-009 DES-AIDS-028 DES-AIDS-049
|
|
672
|
+
def record_chart(handle: ProjectHandle, code: str, png_bytes: bytes) -> int:
|
|
673
|
+
"""Persist a rendered chart as an executed code cell in the project notebook.
|
|
674
|
+
|
|
675
|
+
Mirrors mcp_gateway.run_and_record's ergonomics for chart/image outputs so
|
|
676
|
+
callers don't need to hand-roll enqueue_write boilerplate. Returns the
|
|
677
|
+
stamped execution_count of the new cell.
|
|
678
|
+
|
|
679
|
+
``code`` is never executed against the live Jupyter kernel (REQ-AIDS-040):
|
|
680
|
+
rendering happens locally via ``render_chart`` and ``code`` is stored only
|
|
681
|
+
as a human-readable record of how the chart was produced. Callers must
|
|
682
|
+
ensure ``code`` references only variables already established by a prior
|
|
683
|
+
``mcp_gateway.run_and_record`` call, so the notebook stays consistent if a
|
|
684
|
+
human re-runs it top-to-bottom against the live kernel later.
|
|
685
|
+
|
|
686
|
+
When ``png_bytes`` is a ``RenderedChart`` (the result of ``render_chart``),
|
|
687
|
+
its ``chart_metadata`` is persisted into the new cell's
|
|
688
|
+
``metadata["chart"]`` mapping so ``audit_notebook(..., visual_audit=True)``
|
|
689
|
+
can audit it without re-rendering (REQ-AIDS-061). Plain ``bytes`` (e.g.
|
|
690
|
+
read from a file, not produced by ``render_chart``) leave
|
|
691
|
+
``metadata["chart"]`` unset, preserving the existing "unaudited" finding.
|
|
692
|
+
"""
|
|
693
|
+
stamped_count: dict[str, int] = {}
|
|
694
|
+
|
|
695
|
+
def add_cell(nb):
|
|
696
|
+
execution_count = next_execution_count(nb)
|
|
697
|
+
stamped_count["value"] = execution_count
|
|
698
|
+
cell = nbformat.v4.new_code_cell(code)
|
|
699
|
+
cell["execution_count"] = execution_count
|
|
700
|
+
output = build_image_output(png_bytes)
|
|
701
|
+
output["execution_count"] = execution_count
|
|
702
|
+
cell["outputs"] = [output]
|
|
703
|
+
if isinstance(png_bytes, RenderedChart):
|
|
704
|
+
cell["metadata"]["chart"] = _chart_metadata_dict(png_bytes.chart_metadata)
|
|
705
|
+
nb.cells.append(cell)
|
|
706
|
+
|
|
707
|
+
enqueue_write(handle, add_cell)
|
|
708
|
+
return stamped_count["value"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AI Genomics Scientist skill package."""
|