|
4 | 4 | - `TornadoChartWriter` — sorted bar chart of input sensitivity for one |
5 | 5 | output. The standard Monte Carlo "what moves my output the most" |
6 | 6 | 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. |
7 | 12 |
|
8 | 13 | Architecture: |
9 | 14 | - All chart logic stays in this module so `ExcelBridge` doesn't sprawl. |
10 | 15 | - 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. |
13 | 18 | - Chart creation drops to the raw Excel COM API for things xlwings |
14 | 19 | doesn't expose directly (axis inversion, point-by-point bar |
15 | 20 | colouring). Each COM call is wrapped in best-effort try/except so |
16 | 21 | that even if formatting fails, the underlying data table is still |
17 | 22 | 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. |
18 | 28 |
|
19 | | -Future: RiskProfileChartWriter (cumulative + density), HistogramWriter, |
20 | | -ScenarioComparisonWriter. |
| 29 | +Future: RiskProfileChartWriter (density overlay), ScenarioComparisonWriter. |
21 | 30 | """ |
22 | 31 |
|
23 | 32 | from __future__ import annotations |
24 | 33 |
|
25 | 34 | from dataclasses import dataclass |
26 | 35 | from typing import TYPE_CHECKING, Any |
27 | 36 |
|
| 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 | + |
28 | 50 | if TYPE_CHECKING: |
29 | 51 | from modelrisk_mcp.schemas.results import SensitivityEntry |
30 | 52 |
|
31 | 53 |
|
32 | 54 | # Excel chart-type constants. Picked here so the bridge layer can stay |
33 | 55 | # numeric-literal-free. |
34 | 56 | _XL_BAR_CLUSTERED = 57 |
| 57 | +_XL_LINE = 4 |
| 58 | +_XL_COLUMNS_PLOT_BY = 2 # SetSourceData PlotBy: series are columns |
35 | 59 |
|
36 | 60 | # Layout — top-left corner of chart in points, plus dimensions. |
37 | 61 | _CHART_LEFT = 300 |
38 | 62 | _CHART_TOP = 10 |
39 | 63 | _CHART_WIDTH = 480 |
40 | 64 | _CHART_HEIGHT = 360 |
41 | 65 |
|
| 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 | + |
42 | 73 |
|
43 | 74 | @dataclass(frozen=True) |
44 | 75 | class TornadoChartResult: |
@@ -201,4 +232,242 @@ def _default_sheet_name(output_name: str) -> str: |
201 | 232 | return base[:31] |
202 | 233 |
|
203 | 234 |
|
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