{"spec_id":"lightcurve-transit","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nlightcurve-transit: Astronomical Light Curve\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 92/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove script directory from sys.path to avoid importing local altair.py instead of the package\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nif _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 — Imprint palette / default-style-guide.md\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — positions 1 and 2 for the two series\nCOLOR_OBS = \"#009E73\"  # Imprint position 1 — observed data (brand green, always first)\nCOLOR_MODEL = \"#C475FD\"  # Imprint position 2 — fitted transit model\n\n# Data — simulated exoplanet transit light curve (phase-folded)\nnp.random.seed(42)\n\nn_points = 500\nphase = np.sort(np.random.uniform(0.0, 1.0, n_points))\n\n# Transit parameters\ntransit_center = 0.5\ntransit_width = 0.025\ntransit_depth = 0.01\nlimb_u1, limb_u2 = 0.4, 0.2\nsharpness = 120\n\n# Smooth transit model: tanh ingress/egress with quadratic limb darkening\ndist = np.abs(phase - transit_center)\nbox = 0.5 * (np.tanh(sharpness * (transit_width - dist)) + 1.0)\nmu = np.clip(1.0 - (dist / transit_width) ** 2, 0, 1)\nlimb = 1.0 - limb_u1 * (1 - mu) - limb_u2 * (1 - mu) ** 2\nmodel_flux = 1.0 - transit_depth * box * limb\n\n# Observed flux with Gaussian noise\nflux_err = np.random.uniform(0.0008, 0.0018, n_points)\nflux = model_flux + np.random.normal(0, 1, n_points) * flux_err\n\ndf_obs = pd.DataFrame(\n    {\"phase\": phase, \"flux\": flux, \"flux_err\": flux_err, \"flux_upper\": flux + flux_err, \"flux_lower\": flux - flux_err}\n)\n\n# Dense model curve for smooth overlay\nphase_model = np.linspace(0.0, 1.0, 2000)\ndist_m = np.abs(phase_model - transit_center)\nbox_m = 0.5 * (np.tanh(sharpness * (transit_width - dist_m)) + 1.0)\nmu_m = np.clip(1.0 - (dist_m / transit_width) ** 2, 0, 1)\nlimb_m = 1.0 - limb_u1 * (1 - mu_m) - limb_u2 * (1 - mu_m) ** 2\nmodel_dense = 1.0 - transit_depth * box_m * limb_m\n\ndf_model = pd.DataFrame({\"phase\": phase_model, \"flux\": model_dense})\n\n# Legend helper — invisible points to drive one legend entry per series\ndf_legend = pd.DataFrame(\n    {\n        \"phase\": [phase[0], phase_model[0]],\n        \"flux\": [flux[0], model_dense[0]],\n        \"series\": [\"Observed Data\", \"Transit Model\"],\n    }\n)\nlegend_scale = alt.Scale(domain=[\"Observed Data\", \"Transit Model\"], range=[COLOR_OBS, COLOR_MODEL])\n\n# Axis objects — grid/format only; colors come from configure_axis below\ny_scale = alt.Scale(domain=[0.986, 1.006])\ny_axis = alt.Axis(grid=True, gridOpacity=0.15, gridDash=[4, 4], format=\".3f\", tickCount=6)\nx_axis = alt.Axis(grid=True, gridOpacity=0.12, gridDash=[4, 4], tickCount=10)\n\n# Error bars — increased opacity (was 0.2) so they read clearly against the background\nerror_bars = (\n    alt.Chart(df_obs)\n    .mark_rule(strokeWidth=1, opacity=0.6, color=COLOR_OBS)\n    .encode(\n        x=alt.X(\"phase:Q\", title=\"Orbital Phase\", axis=x_axis),\n        y=alt.Y(\"flux_lower:Q\", scale=y_scale),\n        y2=\"flux_upper:Q\",\n    )\n)\n\n# Data points\npoints = (\n    alt.Chart(df_obs)\n    .mark_circle(size=30, opacity=0.5)\n    .encode(\n        x=alt.X(\"phase:Q\", title=\"Orbital Phase\", axis=x_axis),\n        y=alt.Y(\"flux:Q\", title=\"Relative Flux\", scale=y_scale, axis=y_axis),\n        color=alt.value(COLOR_OBS),\n        tooltip=[\n            alt.Tooltip(\"phase:Q\", title=\"Phase\", format=\".4f\"),\n            alt.Tooltip(\"flux:Q\", title=\"Flux\", format=\".5f\"),\n            alt.Tooltip(\"flux_err:Q\", title=\"Error\", format=\".5f\"),\n        ],\n    )\n)\n\n# Transit model curve\nmodel_line = (\n    alt.Chart(df_model)\n    .mark_line(strokeWidth=2.5, opacity=0.9)\n    .encode(x=alt.X(\"phase:Q\"), y=alt.Y(\"flux:Q\", scale=y_scale), color=alt.value(COLOR_MODEL))\n)\n\n# Invisible points to drive the unified legend\nlegend_points = (\n    alt.Chart(df_legend)\n    .mark_point(size=0, filled=True, opacity=0)\n    .encode(\n        x=alt.X(\"phase:Q\"),\n        y=alt.Y(\"flux:Q\", scale=y_scale),\n        color=alt.Color(\n            \"series:N\",\n            scale=legend_scale,\n            legend=alt.Legend(\n                title=None,\n                orient=\"top-right\",\n                labelFontSize=10,\n                symbolSize=80,\n                padding=10,\n                cornerRadius=4,\n                fillColor=ELEVATED_BG,\n                strokeColor=INK_SOFT,\n                labelColor=INK_SOFT,\n            ),\n        ),\n    )\n)\n\n# Interactive hover — nearest-point selection with crosshair\nnearest = alt.selection_point(nearest=True, on=\"pointerover\", fields=[\"phase\"], empty=False)\n\nselectors = alt.Chart(df_obs).mark_point(size=1, opacity=0).encode(x=\"phase:Q\", y=\"flux:Q\").add_params(nearest)\nhover_rule = (\n    alt.Chart(df_obs)\n    .mark_rule(color=INK_SOFT, strokeWidth=1, strokeDash=[3, 3])\n    .encode(x=\"phase:Q\")\n    .transform_filter(nearest)\n)\nhover_point = (\n    alt.Chart(df_obs)\n    .mark_circle(size=120, color=COLOR_OBS, stroke=COLOR_OBS, strokeWidth=2, opacity=1)\n    .encode(x=\"phase:Q\", y=\"flux:Q\")\n    .transform_filter(nearest)\n)\n\nTITLE = \"lightcurve-transit · python · altair · anyplot.ai\"\n\nchart = (\n    alt.layer(error_bars, points, model_line, legend_points, selectors, hover_rule, hover_point)\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            TITLE,\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            subtitle=\"Phase-folded Kepler photometry with limb-darkened transit model\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=4,\n            anchor=\"start\",\n            offset=8,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n    .configure_title(color=INK, subtitleColor=INK_SOFT)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=10,\n    )\n)\n\n# Save — landscape 3200 × 1800 target\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# PAD-only to exact target canvas (do NOT crop — cropping clips title/axis labels)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}