{"spec_id":"bland-altman-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nbland-altman-basic: Bland-Altman Agreement Plot\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-08-11\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove script directory from path to avoid importing self instead of altair package\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nwhile script_dir in sys.path:\n    sys.path.remove(script_dir)\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nBRAND = \"#009E73\"  # Imprint palette position 1 — ALWAYS first series\nLOA_COLOR = \"#C475FD\"  # Imprint palette position 2 — limits-of-agreement lines\nOUTLIER = \"#AE3030\"  # Imprint semantic anchor — points outside the limits of agreement\n\n# Data: simulated systolic blood pressure from two sphygmomanometers\nnp.random.seed(42)\nn = 80\nmethod1 = np.random.normal(loc=125, scale=15, size=n)\nmethod2 = method1 + np.random.normal(loc=2.5, scale=5, size=n)\n\n# Bland-Altman statistics\nmean_values = (method1 + method2) / 2\ndiff_values = method1 - method2\nmean_diff = np.mean(diff_values)\nstd_diff = np.std(diff_values, ddof=1)\nupper_loa = mean_diff + 1.96 * std_diff\nlower_loa = mean_diff - 1.96 * std_diff\n\ndf = pd.DataFrame({\"Mean\": mean_values, \"Difference\": diff_values})\n\n# Axis ranges with headroom for the limit lines and annotations\nx_min = df[\"Mean\"].min() - 5\nx_max = df[\"Mean\"].max() + 5\ny_min = min(df[\"Difference\"].min(), lower_loa) - 3\ny_max = max(df[\"Difference\"].max(), upper_loa) + 3\n\n# Scatter points — flagged red + diamond shape when outside the 95% limits of\n# agreement (redundant shape encoding keeps the flag CVD-safe, not color-only)\noutlier_predicate = f\"datum.Difference > {upper_loa} || datum.Difference < {lower_loa}\"\nscatter = (\n    alt.Chart(df)\n    .mark_point(size=110, filled=True, opacity=0.7)\n    .encode(\n        x=alt.X(\"Mean:Q\", title=\"Mean of Two Methods (mmHg)\", scale=alt.Scale(domain=[x_min, x_max], zero=False)),\n        y=alt.Y(\n            \"Difference:Q\",\n            title=\"Difference: Method 1 − Method 2 (mmHg)\",\n            scale=alt.Scale(domain=[y_min, y_max], zero=False),\n        ),\n        color=alt.condition(outlier_predicate, alt.value(OUTLIER), alt.value(BRAND)),\n        shape=alt.condition(outlier_predicate, alt.value(\"diamond\"), alt.value(\"circle\")),\n        tooltip=[\n            alt.Tooltip(\"Mean:Q\", format=\".1f\", title=\"Mean\"),\n            alt.Tooltip(\"Difference:Q\", format=\".1f\", title=\"Difference\"),\n        ],\n    )\n)\n\n# Mean bias line (solid)\nmean_line = (\n    alt.Chart(pd.DataFrame({\"y\": [mean_diff]}))\n    .mark_rule(strokeWidth=3, color=BRAND)\n    .encode(y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[y_min, y_max], zero=False)))\n)\n\n# Limits of agreement lines (dashed)\nloa_lines = (\n    alt.Chart(pd.DataFrame({\"y\": [upper_loa, lower_loa]}))\n    .mark_rule(strokeWidth=2, strokeDash=[8, 4], color=LOA_COLOR)\n    .encode(y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[y_min, y_max], zero=False)))\n)\n\n# Annotation labels, framed with an elevated-surface box for legibility over the scatter\nannotation_df = pd.DataFrame(\n    {\n        \"x\": [x_max - 2, x_max - 2, x_max - 2],\n        \"y\": [mean_diff + 1.3, upper_loa + 1.3, lower_loa - 1.7],\n        \"text\": [f\"Mean bias: {mean_diff:.2f}\", f\"+1.96 SD: {upper_loa:.2f}\", f\"-1.96 SD: {lower_loa:.2f}\"],\n    }\n)\nbox_pad_x = (x_max - x_min) * 0.125\nbox_pad_y = (y_max - y_min) * 0.045\nbox_df = annotation_df.assign(\n    x0=annotation_df[\"x\"] - 2 * box_pad_x,\n    x1=annotation_df[\"x\"] + 0.3,\n    y0=annotation_df[\"y\"] - box_pad_y,\n    y1=annotation_df[\"y\"] + box_pad_y,\n)\nannotation_boxes = (\n    alt.Chart(box_df)\n    .mark_rect(fill=ELEVATED_BG, stroke=INK_SOFT, strokeWidth=1, cornerRadius=3, opacity=0.92)\n    .encode(\n        x=alt.X(\"x0:Q\", scale=alt.Scale(domain=[x_min, x_max], zero=False)),\n        x2=\"x1:Q\",\n        y=alt.Y(\"y0:Q\", scale=alt.Scale(domain=[y_min, y_max], zero=False)),\n        y2=\"y1:Q\",\n    )\n)\nannotations = (\n    alt.Chart(annotation_df)\n    .mark_text(align=\"right\", fontSize=13, fontWeight=\"bold\", color=INK)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[x_min, x_max], zero=False)),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[y_min, y_max], zero=False)),\n        text=\"text:N\",\n    )\n)\n\n# Title — length-based fontsize formula (see prompts/plot-generator.md)\ntitle_text = \"bland-altman-basic · python · altair · anyplot.ai\"\ntitle_fontsize = round(16 * min(1.0, 67 / len(title_text)))\n\n# Combine layers, enable pan/zoom, and apply theme-adaptive chrome.\n# Subtitle spells out the red/diamond = outlier semantics so the encoding is\n# self-explanatory without a separate legend.\nchart = (\n    (annotation_boxes + scatter + mean_line + loa_lines + annotations)\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            title_text,\n            subtitle=\"Red diamonds mark points outside the 95% limits of agreement\",\n            fontSize=title_fontsize,\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            anchor=\"middle\",\n            color=INK,\n        ),\n    )\n    .interactive()\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.12,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=11,\n        titleFontSize=13,\n    )\n    .configure_title(color=INK, fontSize=title_fontsize)\n)\n\n# Save PNG then pad to the exact canonical target — never crop (see prompts/library/altair.md)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nTARGET_W, TARGET_H = 3200, 1800\nimg = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\nw, h = img.size\nif w > TARGET_W or h > TARGET_H:\n    raise SystemExit(\n        f\"altair vl-convert produced {w}x{h}, exceeds target {TARGET_W}x{TARGET_H}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif w < TARGET_W or h < TARGET_H:\n    canvas = Image.new(\"RGB\", (TARGET_W, TARGET_H), PAGE_BG)\n    canvas.paste(img, ((TARGET_W - w) // 2, (TARGET_H - h) // 2))\n    canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}