S-curve / earned value
Project Controls — Gantt data from Plotly datasets
Example from the compendium of canonical charts
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