Skip to content

Commit b299c32

Browse files
vosesoftclaude
andcommitted
Add create_histogram_chart + create_cdf_chart tools (0.3.7)
Two new workflow tools render one output's simulation result distribution as a native Excel chart on its own sheet — the ModelRisk Results-Viewer view, persisted (linked) into the workbook: - create_histogram_chart: frequency columns + cumulative-% overlay on a secondary axis, central-80% (P10-P90) band highlighted. - create_cdf_chart: ascending cumulative-probability curve on its own. Both read per-iteration samples from the active .vmrs (get_samples), bin onto round boundaries (_nice_bins), and write a 3-column data table + chart. New DistributionChartWriter in bridge/charts.py reuses reports.py's chart builders (_add_histogram_chart + the bug-#18 bind-with-verification workaround) so a standalone chart is identical to the same chart inside build_executive_report. Idempotent — target sheet (Histogram_<output> / CDF_<output>) is replaced. Bridge: create_histogram_chart / create_cdf_chart on ModelRiskBridge. Tests: 12 new unit tests (mocked xlwings); 606 unit tests green, ruff + mypy clean. Version bump 0.3.6 -> 0.3.7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf5848d commit b299c32

10 files changed

Lines changed: 598 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to ModelRisk MCP. Follows [Keep a Changelog](https://keepach
44

55
## [Unreleased]
66

7+
## [0.3.7] — 2026-06-18
8+
9+
### Distribution charts: `create_histogram_chart` + `create_cdf_chart`
10+
11+
Two new tools render one output's **simulation result distribution** as a native Excel chart on its own sheet — the ModelRisk Results-Viewer view, persisted into the workbook (linked to the spreadsheet, not a screenshot):
12+
13+
- **`create_histogram_chart`** — frequency columns with the cumulative-probability curve overlaid on a secondary % axis and the central-80% (P10-P90) band highlighted.
14+
- **`create_cdf_chart`** — the ascending cumulative-probability curve on its own ("what's the chance the output is below X").
15+
16+
Both read the per-iteration samples from the active `.vmrs` (`get_samples`), bin onto round-number boundaries (`_nice_bins`), and write a 3-column data table (bin centre / frequency / cumulative %) plus the chart. They **reuse the executive report's chart builders** (`_add_histogram_chart` + the bug-#18 bind-with-verification workaround), so a standalone chart looks byte-for-byte identical to the same chart inside `build_executive_report`. Idempotent — a sheet with the target name (`Histogram_<output>` / `CDF_<output>`) is replaced, so re-running after a fresh simulation just refreshes it. The response carries the sheet/chart names plus mean and P10/P50/P90.
17+
718
## [0.3.6] — 2026-06-16
819

920
### Fix: ModelRisk reported "not loaded" on comma-decimal locales (Russian, German, …)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ The server can both read *and* modify your workbook — that's the central diffe
234234

235235
## What's inside
236236

237-
- **50 tools** — 12 reading, 14 building (incl. `create_aggregate` for FFT / Panjer / MC frequency-severity methods), 5 simulation (incl. `run_simulation`, `run_scenarios`, `get_samples`, `restore_cell`, `restore_deterministic_state`), 8 analysis (`compute_distribution`, `fit_and_rank_distributions`, `get_tail_risk`, `fit_tail`, `compute_correlation_matrix`, `compare_distributions`, `backtest_output`, `decompose_uncertainty`), 8 workflow / reporting (incl. `plan_risk_model`, `audit_model`, `diagnose_workbook`, `create_tornado_chart`, `build_drivers_report`, `build_executive_report`, `generate_executive_summary`, `save_workbook_as`), 3 VMRS (`read_vmrs`, `set_active_vmrs`, `list_vmrs_variables`)
237+
- **52 tools** — 12 reading, 14 building (incl. `create_aggregate` for FFT / Panjer / MC frequency-severity methods), 5 simulation (incl. `run_simulation`, `run_scenarios`, `get_samples`, `restore_cell`, `restore_deterministic_state`), 8 analysis (`compute_distribution`, `fit_and_rank_distributions`, `get_tail_risk`, `fit_tail`, `compute_correlation_matrix`, `compare_distributions`, `backtest_output`, `decompose_uncertainty`), 10 workflow / reporting (incl. `plan_risk_model`, `audit_model`, `diagnose_workbook`, `create_tornado_chart`, `create_histogram_chart`, `create_cdf_chart`, `build_drivers_report`, `build_executive_report`, `generate_executive_summary`, `save_workbook_as`), 3 VMRS (`read_vmrs`, `set_active_vmrs`, `list_vmrs_variables`)
238238
- **5 resources**`modelrisk://functions`, `modelrisk://distributions`, `modelrisk://methodology`, `modelrisk://workbook/current`, `modelrisk://audit-rules`
239239
- **5 slash-command prompts**`/build-risk-model`, `/audit-model`, `/interpret-results`, `/add-uncertainty`, `/import-legacy-model`
240240
- **1417-entry function catalogue** extracted directly from the ModelRisk IDL + XLL header

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "modelrisk-mcp"
3-
version = "0.3.6"
3+
version = "0.3.7"
44
description = "Open MCP server bridging Anthropic Claude (and any MCP-compatible client) with the ModelRisk Excel add-in."
55
readme = "README.md"
66
requires-python = ">=3.11"

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"name": "io.github.vosesoftware/modelrisk-mcp",
44
"title": "ModelRisk",
55
"description": "Read, build, fit, and run Monte Carlo risk models in Excel through Vose Software's ModelRisk.",
6-
"version": "0.3.6",
6+
"version": "0.3.7",
77
"packages": [
88
{
99
"registryType": "pypi",
1010
"identifier": "modelrisk-mcp",
11-
"version": "0.3.6",
11+
"version": "0.3.7",
1212
"transport": {
1313
"type": "stdio"
1414
}

src/modelrisk_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.6"
1+
__version__ = "0.3.7"

src/modelrisk_mcp/bridge/charts.py

Lines changed: 274 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,72 @@
44
- `TornadoChartWriter` — sorted bar chart of input sensitivity for one
55
output. The standard Monte Carlo "what moves my output the most"
66
visualization.
7+
- `DistributionChartWriter` — histogram (frequency + cumulative
8+
overlay) or ascending-cumulative (CDF) chart of one output's
9+
simulation sample distribution. The Results-Viewer "what does my
10+
output's distribution look like" visualization, persisted as a native
11+
Excel chart on its own sheet.
712
813
Architecture:
914
- All chart logic stays in this module so `ExcelBridge` doesn't sprawl.
1015
- The writer takes an opened workbook handle (via xlwings) and the
11-
precomputed sensitivity data — it doesn't know anything about
12-
MRService.dll or the simulation pipeline.
16+
precomputed data — it doesn't know anything about MRService.dll or
17+
the simulation pipeline.
1318
- Chart creation drops to the raw Excel COM API for things xlwings
1419
doesn't expose directly (axis inversion, point-by-point bar
1520
colouring). Each COM call is wrapped in best-effort try/except so
1621
that even if formatting fails, the underlying data table is still
1722
written and visible.
23+
- The distribution charts reuse the binning + chart-building helpers in
24+
`reports.py` (`_nice_bins`, `_add_histogram_chart`, the
25+
bind-with-verification workaround for bug #18) rather than
26+
reimplementing the fragile COM, so the standalone tool and the
27+
executive report render identical, style-guide-compliant charts.
1828
19-
Future: RiskProfileChartWriter (cumulative + density), HistogramWriter,
20-
ScenarioComparisonWriter.
29+
Future: RiskProfileChartWriter (density overlay), ScenarioComparisonWriter.
2130
"""
2231

2332
from __future__ import annotations
2433

2534
from dataclasses import dataclass
2635
from typing import TYPE_CHECKING, Any
2736

37+
from modelrisk_mcp.bridge.reports import (
38+
_COLOR_CHART_LINE,
39+
_XL_LINE_STYLE_NONE,
40+
_XL_TICK_MARK_NONE,
41+
_add_histogram_chart,
42+
_axis_scale_format,
43+
_bind_chart_to_range,
44+
_nice_bins,
45+
_percentile,
46+
_style_chart_frame,
47+
_write_histogram_data,
48+
)
49+
2850
if TYPE_CHECKING:
2951
from modelrisk_mcp.schemas.results import SensitivityEntry
3052

3153

3254
# Excel chart-type constants. Picked here so the bridge layer can stay
3355
# numeric-literal-free.
3456
_XL_BAR_CLUSTERED = 57
57+
_XL_LINE = 4
58+
_XL_COLUMNS_PLOT_BY = 2 # SetSourceData PlotBy: series are columns
3559

3660
# Layout — top-left corner of chart in points, plus dimensions.
3761
_CHART_LEFT = 300
3862
_CHART_TOP = 10
3963
_CHART_WIDTH = 480
4064
_CHART_HEIGHT = 360
4165

66+
# Layout for the distribution charts (each on its own sheet). The 3-col
67+
# data table sits in A:C (~150pt); the chart starts past it.
68+
_DIST_CHART_LEFT = 230
69+
_DIST_CHART_TOP = 10
70+
_DIST_CHART_WIDTH = 560
71+
_DIST_CHART_HEIGHT = 360
72+
4273

4374
@dataclass(frozen=True)
4475
class TornadoChartResult:
@@ -201,4 +232,242 @@ def _default_sheet_name(output_name: str) -> str:
201232
return base[:31]
202233

203234

204-
__all__ = ["TornadoChartResult", "TornadoChartWriter"]
235+
# ---------------------------------------------------------------------------
236+
# Distribution charts — histogram + cumulative (the Results-Viewer view)
237+
# ---------------------------------------------------------------------------
238+
239+
240+
@dataclass(frozen=True)
241+
class DistributionChartResult:
242+
"""What was created for a distribution chart. Returned to the MCP
243+
layer so the response can carry the headline percentiles alongside
244+
the sheet/chart identifiers."""
245+
246+
sheet_name: str
247+
chart_name: str
248+
output_name: str
249+
chart_kind: str # "histogram" | "cdf"
250+
sample_count: int
251+
bin_count: int
252+
mean: float
253+
p10: float
254+
p50: float
255+
p90: float
256+
257+
258+
class DistributionChartWriter:
259+
"""Renders one output's simulation-sample distribution as a native
260+
Excel chart on its own sheet.
261+
262+
Two kinds:
263+
- ``histogram`` — frequency columns with the cumulative-probability
264+
line overlaid on a secondary % axis and the central-80% (P10-P90)
265+
band highlighted. This is the full Results-Viewer histogram.
266+
- ``cdf`` — the ascending cumulative-probability curve on its own
267+
(the "what's the chance the output is below X" view).
268+
269+
The sheet holds a 3-column data table (bin centre / frequency /
270+
cumulative %) plus the chart. Idempotent: a target sheet of the same
271+
name is replaced, so the tool is re-runnable as the model evolves.
272+
273+
The binning and chart COM are reused from ``reports.py`` so the
274+
output is byte-for-byte the same style the executive report uses."""
275+
276+
@staticmethod
277+
def write(
278+
book: Any,
279+
output_name: str,
280+
samples: list[float],
281+
*,
282+
chart_kind: str = "histogram",
283+
sheet_name: str | None = None,
284+
) -> DistributionChartResult:
285+
kind = chart_kind.lower().strip()
286+
if kind not in ("histogram", "cdf"):
287+
raise ValueError(
288+
f"chart_kind must be 'histogram' or 'cdf', got {chart_kind!r}"
289+
)
290+
291+
target_sheet = sheet_name or _distribution_sheet_name(output_name, kind)
292+
DistributionChartWriter._remove_existing_sheet(book, target_sheet)
293+
sheet = book.sheets.add(target_sheet, after=book.sheets[-1])
294+
295+
bins = _nice_bins(samples)
296+
# Reuse the report's 3-column writer; the data table lives on the
297+
# visible chart sheet itself (A1:C) so the numbers travel with the
298+
# chart. The chart is placed past column C so they don't overlap.
299+
_write_histogram_data(sheet, bins, anchor_col="A", anchor_row=1)
300+
DistributionChartWriter._style_data_table(sheet, bins.n)
301+
302+
p10 = _percentile(samples, 0.10)
303+
p50 = _percentile(samples, 0.50)
304+
p90 = _percentile(samples, 0.90)
305+
mean = sum(samples) / len(samples) if samples else 0.0
306+
307+
chart_name = ""
308+
if bins.n:
309+
if kind == "histogram":
310+
title = f"Histogram — {output_name}"
311+
ok = _add_histogram_chart(
312+
sheet, helper=sheet, helper_anchor_col="A", bins=bins,
313+
p10=p10, p90=p90, title=title,
314+
left=_DIST_CHART_LEFT, top=_DIST_CHART_TOP,
315+
width=_DIST_CHART_WIDTH, height=_DIST_CHART_HEIGHT,
316+
)
317+
if ok:
318+
chart_name = DistributionChartWriter._last_chart_name(sheet)
319+
else:
320+
title = f"Cumulative probability — {output_name}"
321+
chart_name = _add_cdf_chart(
322+
sheet, helper=sheet, helper_anchor_col="A", bins=bins,
323+
title=title,
324+
left=_DIST_CHART_LEFT, top=_DIST_CHART_TOP,
325+
width=_DIST_CHART_WIDTH, height=_DIST_CHART_HEIGHT,
326+
)
327+
328+
return DistributionChartResult(
329+
sheet_name=target_sheet,
330+
chart_name=chart_name,
331+
output_name=output_name,
332+
chart_kind=kind,
333+
sample_count=len(samples),
334+
bin_count=bins.n,
335+
mean=mean,
336+
p10=p10,
337+
p50=p50,
338+
p90=p90,
339+
)
340+
341+
# ----- internal -------------------------------------------------------
342+
343+
@staticmethod
344+
def _remove_existing_sheet(book: Any, name: str) -> None:
345+
for sheet in list(book.sheets):
346+
if sheet.name == name:
347+
try:
348+
sheet.delete()
349+
except Exception:
350+
pass
351+
return
352+
353+
@staticmethod
354+
def _style_data_table(sheet: Any, n: int) -> None:
355+
try:
356+
sheet.range("A1:C1").api.Font.Bold = True
357+
except Exception:
358+
pass
359+
if n:
360+
try:
361+
sheet.range(f"C2:C{n + 1}").api.NumberFormat = "0.0%"
362+
except Exception:
363+
pass
364+
365+
@staticmethod
366+
def _last_chart_name(sheet: Any) -> str:
367+
"""`_add_histogram_chart` names the chart itself but returns an
368+
int flag, not the name. Read the most-recently-added chart's
369+
name back off the sheet for the response."""
370+
try:
371+
charts = list(sheet.charts)
372+
if charts:
373+
return str(charts[-1].name)
374+
except Exception:
375+
pass
376+
return ""
377+
378+
379+
def _add_cdf_chart(
380+
sheet: Any,
381+
*,
382+
helper: Any,
383+
helper_anchor_col: str,
384+
bins: Any,
385+
title: str,
386+
left: int, top: int, width: int, height: int,
387+
) -> str:
388+
"""Add an ascending-cumulative (CDF) line chart on `sheet`, bound to
389+
the cumulative-% column of the 3-column block at
390+
`helper_anchor_col`. Returns the chart name on success, "" if the
391+
chart couldn't be created or the COM bind failed.
392+
393+
Layout assumption mirrors `_write_histogram_data`: col[0] = bin
394+
centres, col[1] = counts, col[2] = cumulative %, header at row 1.
395+
Binds the cumulative column as the single series, then assigns the
396+
bin centres as its X (category) values so the curve reads against
397+
round-number output values, not the 1..N row index."""
398+
if bins.n == 0:
399+
return ""
400+
try:
401+
chart = sheet.charts.add(left=left, top=top, width=width, height=height)
402+
except Exception:
403+
return ""
404+
bin_col = helper_anchor_col
405+
cum_col = chr(ord(helper_anchor_col) + 2)
406+
data_range = helper.range(f"{cum_col}1:{cum_col}{1 + bins.n}")
407+
try:
408+
chart_api = chart.api[1] if isinstance(chart.api, tuple) else chart.api
409+
chart_api.ChartType = _XL_LINE
410+
except Exception:
411+
pass
412+
bound = _bind_chart_to_range(chart, data_range, plot_by=_XL_COLUMNS_PLOT_BY)
413+
if not bound:
414+
return ""
415+
x_range = helper.range(f"{bin_col}2:{bin_col}{1 + bins.n}")
416+
try:
417+
chart_api = chart.api[1] if isinstance(chart.api, tuple) else chart.api
418+
chart_api.HasTitle = True
419+
chart_api.ChartTitle.Text = title
420+
try:
421+
series = chart_api.SeriesCollection(1)
422+
series.XValues = x_range.api
423+
series.Format.Line.ForeColor.RGB = _COLOR_CHART_LINE
424+
series.Format.Line.Weight = 2.25
425+
try:
426+
series.MarkerStyle = _XL_LINE_STYLE_NONE
427+
except Exception:
428+
pass
429+
except Exception:
430+
pass
431+
_style_chart_frame(chart_api)
432+
# X axis: round-number labels, thinned, no tick marks.
433+
try:
434+
xa = chart_api.Axes(1)
435+
xa.TickLabels.NumberFormat = _axis_scale_format(bins.centres)
436+
xa.TickLabels.Font.Size = 9
437+
xa.TickLabelSpacing = bins.label_every
438+
try:
439+
xa.TickMarkSpacing = bins.label_every
440+
except Exception:
441+
pass
442+
xa.MajorTickMark = _XL_TICK_MARK_NONE
443+
xa.MinorTickMark = _XL_TICK_MARK_NONE
444+
except Exception:
445+
pass
446+
# Y axis: cumulative probability, hard-capped 0..100%.
447+
try:
448+
ya = chart_api.Axes(2)
449+
ya.MinimumScale = 0
450+
ya.MaximumScale = 1.0
451+
ya.MajorUnit = 0.2
452+
ya.TickLabels.NumberFormat = "0%"
453+
ya.TickLabels.Font.Size = 9
454+
except Exception:
455+
pass
456+
chart.name = f"CDF_{title[:20]}"[:31]
457+
except Exception:
458+
pass
459+
return str(chart.name)
460+
461+
462+
def _distribution_sheet_name(output_name: str, kind: str) -> str:
463+
"""Excel limits sheet names to 31 chars; truncate as needed."""
464+
prefix = "Histogram_" if kind == "histogram" else "CDF_"
465+
return f"{prefix}{output_name}"[:31]
466+
467+
468+
__all__ = [
469+
"DistributionChartResult",
470+
"DistributionChartWriter",
471+
"TornadoChartResult",
472+
"TornadoChartWriter",
473+
]

0 commit comments

Comments
 (0)