Chris Parmer — home

S-curve / earned value

Project Controls — Gantt data from Plotly datasets

Example from the compendium of canonical charts

Project Controls — S-curve / earned value (Gantt data from Plotly datasets)

Python Code

"""Project Controls — S-curve / earned value (Gantt data from Plotly datasets)."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go

URL = "https://raw.githubusercontent.com/plotly/datasets/master/GanttChart.csv"
COST_PER_DAY = 1000  # $1 000 / day / task — synthetic budget


def generate():
    print("fetching GanttChart from Plotly datasets …")
    df = fetch_csv(URL)
    print("columns:", df.columns.tolist())
    print(df.head(3))

    df.columns = [c.strip() for c in df.columns]
    # Expected: Task, Start, Finish, Resource (or Duration)

    # Parse dates
    if "Start" in df.columns:
        df["start"] = pd.to_datetime(df["Start"], errors="coerce")
    if "Finish" in df.columns:
        df["finish"] = pd.to_datetime(df["Finish"], errors="coerce")
    elif "Duration" in df.columns:
        df["finish"] = df["start"] + pd.to_timedelta(df["Duration"].astype(int), unit="D")

    df = df.dropna(subset=["start", "finish"])
    df["duration"] = (df["finish"] - df["start"]).dt.days.clip(lower=1)
    df["budget"] = df["duration"] * COST_PER_DAY

    # Project timeline
    proj_start = df["start"].min()
    proj_end   = df["finish"].max()
    dates = pd.date_range(proj_start, proj_end, freq="D")

    # Planned Value (PV): spread each task's budget uniformly over its duration
    pv_daily = pd.Series(0.0, index=dates)
    for _, row in df.iterrows():
        task_dates = pd.date_range(row["start"], row["finish"] - pd.Timedelta("1D"), freq="D")
        task_dates = task_dates[task_dates.isin(dates)]
        if len(task_dates) > 0:
            daily_cost = row["budget"] / len(task_dates)
            pv_daily[task_dates] += daily_cost

    pv_cum = pv_daily.cumsum()

    # Earned Value (EV): 90% of PV with a 5-day lag (simulated delay)
    ev_cum = (pv_cum.shift(5, freq="D").reindex(dates, fill_value=0) * 0.90).clip(upper=pv_cum.max())

    # Actual Cost (AC): EV × 1.1 cost overrun
    ac_cum = (ev_cum * 1.1).clip(upper=pv_cum.max() * 1.15)

    fig = go.Figure()

    fig.add_trace(go.Scatter(
        x=pv_cum.index, y=pv_cum.values,
        mode="lines", name="Planned Value (PV)",
        line=dict(color=VIOLET, width=2.5),
        hovertemplate="%{x|%Y-%m-%d}: $%{y:,.0f}<extra>PV</extra>",
    ))

    fig.add_trace(go.Scatter(
        x=dates, y=ev_cum.values,
        mode="lines", name="Earned Value (EV)",
        line=dict(color=GREEN, width=2.5),
        hovertemplate="%{x|%Y-%m-%d}: $%{y:,.0f}<extra>EV</extra>",
    ))

    fig.add_trace(go.Scatter(
        x=dates, y=ac_cum.values,
        mode="lines", name="Actual Cost (AC)",
        line=dict(color=TEAL, width=2.5),
        hovertemplate="%{x|%Y-%m-%d}: $%{y:,.0f}<extra>AC</extra>",
    ))

    # Annotations
    mid = dates[len(dates) // 2]
    fig.add_annotation(
        x=mid, y=pv_cum[mid] * 1.03,
        text="Schedule Variance = EV − PV",
        showarrow=True, arrowhead=2,
        ax=60, ay=-40,
        font=dict(size=10, color="#60646c"),
    )

    fig.update_layout(
        xaxis=dict(title=""),
        yaxis=dict(title="Cumulative Cost ($)", tickprefix="$", tickformat=",.0f"),
        legend=dict(orientation="h", y=-0.14),
        margin=dict(t=50, b=70, l=90, r=40),
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly