Chris Parmer — home

Hertzsprung-Russell diagram

HYG star catalog v4.1

Example from the compendium of canonical charts

Hertzsprung-Russell diagram — HYG star catalog v4.1

Python Code

"""Hertzsprung-Russell diagram — HYG star catalog v4.1.

Styled after the classic dark HR diagram on Wikipedia
(https://en.wikipedia.org/wiki/Hertzsprung-Russell_diagram, File:HRDiagram.png):
black field, four labelled axes (colour + spectral class / temperature on the
horizontal pair, luminosity + absolute magnitude on the vertical pair), and the
luminosity-class bands sketched over the scatter.
"""


import io
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import requests, warnings

URL = "https://raw.githubusercontent.com/astronexus/HYG-Database/master/hyg/CURRENT/hygdata_v41.csv"

# ── Physical conversions ──────────────────────────────────────────────────────
M_SUN = 4.83  # Sun's absolute visual magnitude → luminosity zero-point


def bv_to_temp(bv):
    """Ballesteros (2012) effective temperature from B–V colour index (K)."""
    bv = np.asarray(bv, dtype=float)
    return 4600.0 * (1.0 / (0.92 * bv + 1.7) + 1.0 / (0.92 * bv + 0.62))


def temp_to_bv(temp):
    """Invert bv_to_temp numerically over the plotted colour range."""
    grid = np.linspace(-0.4, 2.4, 4000)
    t = bv_to_temp(grid)
    order = np.argsort(t)
    return float(np.interp(temp, t[order], grid[order]))


def absmag_to_lum(m):
    """Luminosity in solar units from absolute magnitude."""
    return 10.0 ** ((M_SUN - np.asarray(m, dtype=float)) / 2.5)


# ── Luminosity-class ridge lines (approximate empirical loci) ─────────────────
# Each is a hand-tabulated polyline in (B–V, absolute magnitude) space.
ZONES = {
    "Ia  Supergiants": [(-0.25, -7.0), (0.0, -7.1), (0.6, -7.3), (1.2, -7.4), (1.9, -7.3)],
    "Ib": [(-0.25, -5.0), (0.0, -5.0), (0.6, -5.2), (1.2, -5.3), (1.9, -5.2)],
    "II  Bright giants": [(-0.15, -2.6), (0.3, -2.4), (0.8, -2.3), (1.3, -2.2), (1.8, -2.1)],
    "III  Giants": [(0.55, 1.0), (0.8, 0.8), (1.0, 0.6), (1.2, 0.3), (1.5, -0.3), (1.75, -0.6)],
    "IV  Subgiants": [(0.35, 2.6), (0.55, 2.9), (0.75, 3.1), (0.95, 2.9), (1.1, 2.6)],
    "V  Main sequence": [
        (-0.30, -2.8), (-0.20, -1.0), (-0.10, 0.3), (0.00, 1.1), (0.15, 2.0),
        (0.30, 2.7), (0.45, 3.5), (0.60, 4.5), (0.70, 5.2), (0.81, 5.9),
        (1.00, 6.7), (1.15, 7.6), (1.40, 8.8), (1.60, 11.4), (1.80, 13.4), (2.00, 15.0),
    ],
    "White dwarfs": [(-0.10, 10.6), (0.10, 11.6), (0.30, 13.0), (0.50, 14.4), (0.70, 15.8)],
}

# Where to drop each band's text label (B–V, abs mag) and its anchor.
ZONE_LABELS = {
    "Ia  Supergiants": (1.55, -7.6, "left"),
    "Ib": (1.55, -5.5, "left"),
    "II  Bright giants": (1.45, -2.5, "left"),
    "III  Giants": (1.78, -0.7, "left"),
    "IV  Subgiants": (1.12, 2.4, "left"),
    "V  Main sequence": (1.25, 8.4, "left"),
    "White dwarfs": (0.72, 16.1, "left"),
}

LINE_COLOR = "#d36bd0"   # magenta ridge lines, as in the reference
AXIS_COLOR = "#9fb0d6"   # cool blue-grey for axis lines / ticks on black
LABEL_BG = "rgba(0,0,0,0.55)"


def generate():
    print("downloading HYG catalog (full) …")
    warnings.filterwarnings("ignore")
    requests.packages.urllib3.disable_warnings()

    r = requests.get(URL, verify=False, timeout=180)
    r.raise_for_status()
    df = pd.read_csv(io.BytesIO(r.content), low_memory=False)
    print(f"  raw rows: {len(df)}")

    for c in ("ci", "absmag", "mag", "dist"):
        df[c] = pd.to_numeric(df[c], errors="coerce")
    df = df.dropna(subset=["ci", "absmag"])

    # Combine a bright (apparent mag) sample — which fills the upper main
    # sequence, giants and supergiants — with a nearby volume-limited sample,
    # which supplies the faint lower main sequence and white dwarfs the bright
    # cut alone would miss. Together they trace the full HR sweep.
    bright = df[df["mag"] < 6.5]
    nearby = df[df["dist"] < 75]
    stars = pd.concat([bright, nearby]).drop_duplicates(subset="id")
    if len(stars) > 14000:
        stars = stars.sample(14000, random_state=7)
    print(f"  plotted stars: {len(stars)}  (bright {len(bright)}, nearby {len(nearby)})")

    temp = bv_to_temp(stars["ci"])
    lum = absmag_to_lum(stars["absmag"])
    names = stars["proper"].fillna("").astype(str)
    names = names.mask(names.eq(""), "—")

    customdata = np.column_stack([
        names,
        stars["spect"].fillna("—").astype(str),
        temp,
        lum,
        stars["absmag"],
        stars["ci"],
        stars["dist"].fillna(np.nan),
        stars["con"].fillna("—").astype(str),
    ])

    hover = (
        "<b>%{customdata[0]}</b>"
        "<br>Spectral type   %{customdata[1]}"
        "<br>Temperature    %{customdata[2]:,.0f} K"
        "<br>Luminosity        %{customdata[3]:.3g} L<sub>☉</sub>"
        "<br>Abs. magnitude %{customdata[4]:.2f}"
        "<br>Colour (B–V)    %{customdata[5]:.2f}"
        "<br>Distance          %{customdata[6]:.1f} pc"
        "<br>Constellation    %{customdata[7]}"
        "<extra></extra>"
    )

    fig = go.Figure()

    # ── Luminosity-class ridge lines ──────────────────────────────────────────
    for name, pts in ZONES.items():
        xs, ys = zip(*pts)
        fig.add_trace(go.Scatter(
            x=xs, y=ys, mode="lines",
            line=dict(color=LINE_COLOR, width=1.6, shape="spline"),
            opacity=0.85, hoverinfo="skip", showlegend=False,
        ))

    # ── Stars ────────────────────────────────────────────────────────────────
    fig.add_trace(go.Scattergl(
        x=stars["ci"], y=stars["absmag"], mode="markers",
        marker=dict(
            color=stars["ci"],
            colorscale=[
                [0.00, "#9bb8ff"],   # blue-white (hot O/B)
                [0.18, "#cfe0ff"],   # white (A)
                [0.34, "#fbf5e0"],   # yellow-white (F)
                [0.50, "#ffe48a"],   # yellow (G)
                [0.68, "#ffb04d"],   # orange (K)
                [1.00, "#ff5a3c"],   # red (M)
            ],
            cmin=-0.3, cmax=2.0,
            size=3, opacity=0.85,
            line=dict(width=0),
            showscale=False,
        ),
        customdata=customdata,
        hovertemplate=hover,
        showlegend=False,
    ))

    # Invisible anchor traces: an overlaying axis only renders its ticks when a
    # trace is assigned to it, so give the two top axes one transparent point each.
    for ax in ("x2", "x3"):
        fig.add_trace(go.Scatter(
            x=[-0.3, 1.6], y=[0, 0], xaxis=ax, yaxis="y",
            mode="markers", marker=dict(opacity=0),
            hoverinfo="skip", showlegend=False,
        ))
    fig.add_trace(go.Scatter(
        x=[0, 0], y=[0, 10], xaxis="x", yaxis="y2",
        mode="markers", marker=dict(opacity=0),
        hoverinfo="skip", showlegend=False,
    ))

    # ── Axis tick maps ─────────────────────────────────────────────────────────
    # Vertical: plot absolute magnitude (left axis relabelled as luminosity,
    # right axis kept as magnitude). A linear magnitude scale *is* a log
    # luminosity scale, so the powers-of-ten luminosity ticks land on a grid.
    lum_powers = [1e5, 1e4, 1e3, 1e2, 10, 1, 0.1, 1e-2, 1e-3, 1e-4]
    lum_labels = ["100 000", "10 000", "1000", "100", "10", "1",
                  "0.1", "0.01", "0.001", "0.0001"]
    lum_tickvals = [M_SUN - 2.5 * np.log10(p) for p in lum_powers]
    y_range = [16.5, -8.0]  # faint (bottom) → bright (top)

    mag_ticks = [-5, 0, 5, 10, 15]

    # Horizontal: colour on the bottom; spectral class and temperature stacked
    # on top, all sharing the colour range.
    x_range = [-0.4, 2.25]
    spectral = [("O", -0.30), ("B", -0.17), ("A", 0.08), ("F", 0.42),
                ("G", 0.70), ("K", 1.10), ("M", 1.65)]
    spec_vals = [bv for _, bv in spectral]
    spec_text = [s for s, _ in spectral]

    temps = [30000, 10000, 7500, 6000, 5000, 4000, 3000]
    temp_vals = [temp_to_bv(t) for t in temps]
    temp_text = [f"{t:,}".replace(",", " ") for t in temps]

    # Reserve vertical room above the plot for the two stacked top axes.
    PLOT_TOP = 0.90

    fig.update_layout(
        paper_bgcolor="#000000",
        plot_bgcolor="#000000",
        font=dict(family=FONT, color=AXIS_COLOR, size=12),
        margin=dict(t=96, b=64, l=92, r=92),
        autosize=True,  # interactive chart fills its container; PNG size set in to_image()
        showlegend=False,
        hoverlabel=dict(
            bgcolor="rgba(10,12,24,0.92)",
            bordercolor=AXIS_COLOR,
            font=dict(color="#e8edf7", family=FONT, size=12),
            align="left",
        ),
        # Bottom: colour (B–V)
        xaxis=dict(
            title=dict(text="Colour&nbsp; (B–V)", font=dict(color=AXIS_COLOR)),
            range=x_range, domain=[0, 1], anchor="y",
            color=AXIS_COLOR, zeroline=False,
            gridcolor="rgba(159,176,214,0.10)", showgrid=True,
            ticks="outside", tickcolor=AXIS_COLOR, ticklen=4,
        ),
        # Top inner: spectral class
        xaxis2=dict(
            title=dict(text="Spectral class", font=dict(color=AXIS_COLOR)),
            range=x_range, overlaying="x", side="top", anchor="y",
            tickvals=spec_vals, ticktext=spec_text,
            tickfont=dict(size=14, color="#cfe0ff"),
            showgrid=False, zeroline=False,
            ticks="outside", tickcolor=AXIS_COLOR, ticklen=4,
        ),
        # Top outer: temperature
        xaxis3=dict(
            title=dict(text="Temperature (K)", font=dict(color="#8fa6cf", size=11)),
            range=x_range, overlaying="x", side="top",
            anchor="free", position=1.0,
            tickvals=temp_vals, ticktext=temp_text,
            tickfont=dict(size=11, color="#8fa6cf"),
            showgrid=False, zeroline=False,
            ticks="outside", tickcolor="#8fa6cf", ticklen=3,
        ),
        # Left: luminosity (Sun = 1)
        yaxis=dict(
            title=dict(text="Luminosity&nbsp; (Sun = 1)", font=dict(color=AXIS_COLOR)),
            range=y_range, domain=[0, PLOT_TOP], anchor="x",
            tickvals=lum_tickvals, ticktext=lum_labels,
            color=AXIS_COLOR, zeroline=False,
            gridcolor="rgba(159,176,214,0.10)", showgrid=True,
            ticks="outside", tickcolor=AXIS_COLOR, ticklen=4,
        ),
        # Right: absolute magnitude
        yaxis2=dict(
            title=dict(text="Absolute magnitude", font=dict(color=AXIS_COLOR)),
            range=y_range, overlaying="y", side="right", anchor="x",
            tickvals=mag_ticks, ticktext=[f"{m:+d}".replace("+0", "0") for m in mag_ticks],
            color=AXIS_COLOR, zeroline=False, showgrid=False,
            ticks="outside", tickcolor=AXIS_COLOR, ticklen=4,
        ),
    )

    # ── Zone labels (with their own background, per the reference) ────────────
    annotations = []
    for name, (x, y, anchor) in ZONE_LABELS.items():
        annotations.append(dict(
            x=x, y=y, xref="x", yref="y", text=name, showarrow=False,
            xanchor=anchor, font=dict(size=11, color="#f0c8ee"),
            bgcolor=LABEL_BG, borderpad=2,
        ))
    fig.update_layout(annotations=annotations)

    # ── Write outputs (custom save: the site's light theme doesn't apply on a
    # deliberately black chart) ────────────────────────────────────────────────
    (OUT / f"chart-{CHART_NUM:02d}.json").write_text(fig.to_json())
    png_path = OUT / f"chart-{CHART_NUM:02d}.png"
    png_path.write_bytes(fig.to_image(format="png", width=1280, height=800, scale=1))
    make_thumb(png_path)
    print(f"  saved chart-{CHART_NUM:02d}.json + chart-{CHART_NUM:02d}.png + thumb")


if __name__ == "__main__":
    generate()

Made with Plotly