Traway didn’t begin in a boardroom — it began in a hostel room, with three students trying to figure out how to get home.
At NIT Srinagar, every semester break felt like a small crisis: thousands of students from across India trying to leave a region, for their homes, with no railway, limited flights, and unpredictable highways. Their was no system built for us, so we had to build our own.
We were three co-founders — Rohit, Rahi, and I (Yash) — who decided that if logistics in Kashmir couldn’t be solved top-down, we’d start from the ground up. What began as a WhatsApp group + Google Form turned into a structured, event-based travel network that moved thousands of students safely across states.
As the wheels started turning, our team grew — Pratyush, Vinayak, Gowtham, Vansh, massive shout-out to them, they helped Traway grow its reach and then soon after a group of passionate juniors joined in, helping with operations, communication, and coordination on the ground. And through it all, we were guided by Asst. Prof. Noor Zaman Khan and Assoc. Saad Parvez — mentors whose trust and encouragement kept us steady when things looked impossible. What started as three people soon became a 10+ member student-led company, running a system that somehow worked despite every reason it shouldn’t in a volatile region like kashmir.
Traway was more than a startup, it was our first experiment in thinking like entrepreneurs. We learned to plan under uncertainty, take calculated risks, and make decisions when there wasn’t enough data yet — the same mindset that fuels every founder’s first venture.
Looking back, we realized that what we once labeled as failures were actually the moments that taught us how to build better, think sharper, and lead with resilience. They weren’t setbacks — they were our earliest successes, just disguised as lessons and we this report we ain to share those learnings with you, the reader, This report isn’t just about routes, numbers, or margins — it’s about how a small group of students built a working system out of chaos and learned the fundamentals of business, leadership, and risk the hard way — by actually doing it.
Setting the Stage
In this report, we’ve intentionally stepped away from showing raw revenue or profit numbers. While those figures matter, they rarely tell the full story of a young business. What truly shaped Traway was not the balance sheet — it was the behavior of the people we served, and how those patterns guided our decisions. This report focuses on those learnings: how customers moved, chose, and changed — and what that taught us about building something that works in the real world.
Before any analysis, we had to get our data house in order — trips, bookings, costs, revenue, feedback, the whole puzzle. Nothing flashy here, just the quiet groundwork that makes everything else possible. Think of it as laying out the map before starting the journey.
Show code
import pandas as pd, numpy as np, matplotlib.pyplot as plt, seaborn as snstrips = pd.read_csv("../data/processed/trips.csv")bookings = pd.read_csv("../data/processed/bookings.csv")costs = pd.read_csv("../data/processed/costs.csv")revenue = pd.read_csv("../data/processed/revenue.csv")revenue_event = pd.read_csv("../data/processed/revenue_by_event.csv")feedback = pd.read_csv("../data/processed/feedback.csv")students=pd.read_csv("../data/processed/students.csv")print(f"Trips: {len(trips)}, Bookings: {len(bookings)}, Costs: {len(costs)}, Revenue: {len(revenue)}, Feedback: {len(feedback)}")#| label: section-1b-data-prep#| warning: false#| message: falseimport numpy as npimport pandas as pdimport plotly.express as pximport plotly.graph_objects as go# -----------------------------# Brand palette# -----------------------------TRAWAY_COLORS = {"primary": "#1853C9", # Vibrant blue (your brand color)"accent": "#f18f01", # Warm orange (complementary for CTAs, highlights)"neutral": "#64748B", # Slate gray (professional, readable)"light": "#dde0bd", # Light blue-gray (backgrounds, grids)"danger": "#CF4848", # Bright red (errors, refunds, warnings)}color_map = {"commute": TRAWAY_COLORS["primary"],"recreational": TRAWAY_COLORS["accent"],"emergency": TRAWAY_COLORS["danger"],}# -----------------------------# Data hygiene & derived fields# -----------------------------trips_nc = trips.loc[trips["cancellation_flag"] ==False].copy()# Compute realized_bookings = paid − refundedbkg_counts = ( bookings.groupby(["trip_id","payment_status"]) .size() .unstack(fill_value=0) .rename(columns=str.lower))paid = bkg_counts.get("paid", 0)refd = bkg_counts.get("refunded", 0)realized_by_trip = (paid - refd).rename("realized_bookings").reset_index()rev_keep = [c for c in ["trip_id","event_batch_id","trip_type","realized_revenue","total_cost","profit","profit_margin"] if c in revenue.columns]revt = revenue[rev_keep].merge(realized_by_trip, on="trip_id", how="left")if"trip_type"notin revt.columns: revt = revt.merge(trips_nc[["trip_id","trip_type"]], on="trip_id", how="left")revt = revt[revt["trip_id"].isin(trips_nc["trip_id"])].copy()revt["realized_bookings"] = revt["realized_bookings"].fillna(0).clip(lower=0).astype(int)# ----- Event-level aggregationseat_cols = trips_nc[["trip_id","event_batch_id","total_seats"]]df_trip = revt.merge(seat_cols, on=["trip_id","event_batch_id"], how="left")event_agg = ( df_trip.groupby("event_batch_id", as_index=False) .agg( realized_bookings_sum=("realized_bookings","sum"), seats_sum=("total_seats","sum"), realized_revenue_sum=("realized_revenue","sum"), total_cost_sum=("total_cost","sum") ))event_agg["event_profit"] = event_agg["realized_revenue_sum"] - event_agg["total_cost_sum"]event_agg["event_profit_margin"] = np.where( event_agg["realized_revenue_sum"]>0, event_agg["event_profit"]/event_agg["realized_revenue_sum"], np.nan)event_agg["event_occupancy"] = event_agg["realized_bookings_sum"]/event_agg["seats_sum"]event_type = ( trips_nc.groupby("event_batch_id")["trip_type"] .agg(lambda s: s.mode().iat[0] iflen(s.mode()) else s.iloc[0]) .reset_index() .rename(columns={"trip_type":"event_trip_type"}))event_agg = event_agg.merge(event_type, on="event_batch_id", how="left")commute_median_cost = event_agg.loc[event_agg["event_trip_type"]=="commute","total_cost_sum"].replace(0,np.nan).median()commute_median_cost =1.0if pd.isna(commute_median_cost) or commute_median_cost==0else commute_median_costevent_agg["cost_index_vs_commute"] = event_agg["total_cost_sum"]/commute_median_costevent_agg["marker_size"] = (np.sqrt(event_agg["cost_index_vs_commute"].clip(lower=0))*18).clip(8,48)# ----- Trip-level index for boxplotcommute_trip_median_rev = revt.query("trip_type=='commute' and realized_revenue>0")["realized_revenue"].median()commute_trip_median_rev =1.0if pd.isna(commute_trip_median_rev) or commute_trip_median_rev==0else commute_trip_median_revrevt["rev_index_vs_commute"] = np.where( revt["realized_revenue"]>0, revt["realized_revenue"]/commute_trip_median_rev, np.nan)# ----- Business mixmix = ( revt.groupby("trip_type", as_index=False)["realized_bookings"] .sum() .rename(columns={"realized_bookings":"bookings_sum"}))mix = mix[mix["bookings_sum"]>0]# Overall KPIs (sanitized for public - no financial totals)avg_margin_pct = np.nanrealized_rev = revenue_event["realized_revenue"].fillna(0).sum()total_profit = revenue_event["profit"].sum()if realized_rev >0: avg_margin_pct =round((total_profit / realized_rev) *100, 2)kpis = {"Total Trips": int(len(trips)),"Total Students (bookings)": int(bookings["student_id"].nunique()),"Avg Profit Margin (%)": avg_margin_pct,"Avg Rating (★)": round(feedback["rating"].dropna().mean(), 2),}pd.DataFrame(kpis, index=["KPIs"]).T
(Figure 1: Core operational KPIs capturing the scale, customer base, efficiency, and satisfaction of Traway’s operations.)
Even in its rawest form, this snapshot says a lot — steady trip volume, strong student engagement, sustainable margins, and a healthy feedback loop.
Understanding Traway’s Business Model
Traway operated as a structured, event-based mobility service for students and travelers across Kashmir. Instead of running daily routes like a traditional transport operator, we organized seasonal and demand-driven events, each one planned, priced, and executed as a standalone operation.
Our model revolved around three main event types:
Commute Events: Regular semester-break operations connecting campuses to major northern cities via Srinagar <-> Jammu route (the core of Traway’s revenue and reliability.)
Recreational Events: Curated group trips to destinations like Gulmarg, Pahalgam, and Yusmarg — designed for experience and increasing reach.
Emergency & Special Events: Rapid-response evacuations and logistics during sudden unrest or weather crises — This event tested our preparedness, our network, and our supply chain. When in the dawn at around 2:00am we were suddenly informed that college needs to be evacuated by 8:00am due to extremely dangerous situations, we were prepared enough to handle that logistical nightmare and had the funds required to make this possible. We’ll dive deeper into these later, but to grasp the gravity of what teams faced on the ground, see:
fig_donut = go.Figure( go.Pie( labels=mix["trip_type"].str.capitalize(), values=mix["bookings_sum"], hole=0.55, marker=dict(colors=[color_map.get(t,"#864545ff") for t in mix["trip_type"]]), sort=False ))fig_donut.update_layout( title="Business Mix — Share of Realized Bookings by Trip Type", margin=dict(l=40, r=40, t=70, b=60), showlegend=True)fig_donut.show()
(Figure 2: Distribution of bookings across Traway’s three event categories — commute, recreational, and emergency operations.)
The mix shows how Traway’s backbone was built on reliable semester commutes, while nearly a third of activity came from emergency operations — a striking reminder that stability and unpredictability often ran side by side in Kashmir.
(Figure 3: Indexed trip revenue comparison showing commute events as the financial anchor, with recreational trips operating at smaller margins but higher engagement value.)
Commute trips clearly carried the financial weight — consistent, predictable, and large enough to keep operations stable. Recreational trips, though smaller in scale and sometimes unprofitable, played a different role: they kept Traway visible, exciting, and continuously connected with its audience. In hindsight, this balance between steady revenue and community engagement became a quiet engine for long-term retention.
Show code
import numpy as npimport plotly.graph_objects as go# Use existing TRAWAY_COLORS declared earliercolor_map = {"commute": TRAWAY_COLORS["primary"],"recreational": TRAWAY_COLORS["accent"],"emergency": TRAWAY_COLORS["neutral"],}fig_bubble = go.Figure()# Create traces with conditional coloring based on profitabilityfor t, symbol in [("commute", "circle"), ("recreational", "circle")]: sub = event_agg[event_agg["event_trip_type"] == t].copy()iflen(sub) ==0:continue# Assign colors: danger red for losses, brand color for profits sub["marker_color"] = sub["event_profit_margin"].apply(lambda pm: TRAWAY_COLORS["danger"] if pm <0else color_map[t] ) fig_bubble.add_trace( go.Scatter( x=(sub["event_occupancy"] *100).round(1), y=(sub["event_profit_margin"] *100).round(1), mode="markers", name=t.capitalize(), marker=dict( size=sub["marker_size"], sizemode="diameter", sizemin=12if t =="emergency"else8, # Even larger for emergency symbol=symbol, color=sub["marker_color"], opacity=1.0if t =="emergency"else0.9, # Full opacity for emergency line=dict( width=3if t =="emergency"else1.5, # Thicker border color="white" ) ), customdata=np.stack( [ sub["cost_index_vs_commute"].round(2), sub["event_batch_id"], ], axis=-1 ), hovertemplate="<b>%{customdata[1]}</b><br>""Occupancy: %{x:.1f}%<br>""Profit margin: %{y:.1f}%<br>""Scale (cost index): %{customdata[0]}×""<extra></extra>" ) )# Add zero line to highlight profit/loss thresholdfig_bubble.add_shape(type="line", xref="paper", x0=0, x1=1, yref="y", y0=0, y1=0, line=dict(color=TRAWAY_COLORS["light"], width=2, dash="dash"))# Calculate dynamic y-axis range to show all pointsy_min = (event_agg["event_profit_margin"] *100).min()y_max = (event_agg["event_profit_margin"] *100).max()y_padding = (y_max - y_min) *0.1# 10% paddingfig_bubble.update_layout( title="Event Scale vs. Profitability Percentage For Commute and Recreational", xaxis=dict(title="Weighted Occupancy (%)"), yaxis=dict( title="Profit Margin (%)",range=[y_min - y_padding, y_max + y_padding] # Explicit range to show all data ), plot_bgcolor="white", legend=dict(orientation="h", y=1.12, x=0), margin=dict(l=40, r=40, t=80, b=60))fig_bubble.update_xaxes(showgrid=True, gridcolor=TRAWAY_COLORS["light"])fig_bubble.update_yaxes(showgrid=True, gridcolor=TRAWAY_COLORS["light"])fig_bubble.show()
(Figure 4: Event scale vs. profitability — bubbles sized by indexed total cost; red overlays mark loss-making events.)
Show code
import numpy as npimport pandas as pdimport plotly.express as pximport plotly.graph_objects as go# --- Traway palette# Trip type per event from revenue (fallback to trips if needed)event_trip_types = ( revenue[["event_batch_id","trip_type"]] .dropna() .drop_duplicates())rev_ev = revenue_event.merge(event_trip_types, on="event_batch_id", how="left")rev_ev["trip_type"] = rev_ev["trip_type"].fillna("Unknown")# Clean numeric columnsfor c in ["profit_margin","realized_revenue"]:if c in rev_ev.columns: rev_ev[c] = pd.to_numeric(rev_ev[c], errors="coerce")# Display margin (do NOT overwrite source): force -100% if realized_revenue == 0 (emergency/charity)rev_ev["display_margin"] = rev_ev["profit_margin"]rev_ev.loc[rev_ev["realized_revenue"].fillna(0) ==0, "display_margin"] =-1.0# Sort for readabilityrev_ordered = rev_ev.sort_values("display_margin", ascending=False).copy()# Colors: primary for >=0, danger for <0bar_colors = np.where(rev_ordered["display_margin"] >=0, TRAWAY_COLORS["primary"], TRAWAY_COLORS["danger"])# Build chart (horizontal bars)fig = px.bar( rev_ordered, x="display_margin", y="event_batch_id", orientation="h", labels={"display_margin": "Profit Margin (%)", "event_batch_id": "Event Batch"}, hover_data={"trip_type": True,"display_margin": ":.1%","profit_margin": False# hide raw if you prefer; we’re showing display_margin },)fig.update_traces(marker_color=bar_colors, hovertemplate="<b>%{y}</b><br>Type: %{customdata[0]}<br>Profit margin: %{x:.1%}<extra></extra>")# Zero line, percent ticks, tidy layoutfig.update_layout( title="Profit Margin by Event", xaxis_tickformat=".0%", xaxis=dict(zeroline=True, zerolinewidth=1.5, zerolinecolor=TRAWAY_COLORS["light"]), plot_bgcolor="white", margin=dict(l=40, r=40, t=80, b=60), showlegend=False)# Annotate emergency rows (trip_type == 'emergency')if"trip_type"in rev_ordered.columns: em_mask = rev_ordered["trip_type"].str.lower().eq("emergency")for _, r in rev_ordered.loc[em_mask].iterrows(): fig.add_annotation( x=r["display_margin"], y=r["event_batch_id"], text="Emergency (Fully Self-Funded)", showarrow=False, font=dict(color="white"if r["display_margin"] <0else"black", size=11), bgcolor=TRAWAY_COLORS["danger"] if r["display_margin"] <0else TRAWAY_COLORS["primary"], xanchor="left", yshift=0 )fig.show()
(Figure 5: Profit margin by event — the honest lineup of which trips paid the bills and which ones paid in experience instead.)
Each bar tells its own small truth: commutes kept the lights on, recreationals flirted with breakeven, and that one red emergency bar — a fully self-funded operation — stands as a badge of responsibility rather than revenue. It’s the price of showing up when it mattered most.
Next we wanted to see which routes were the most profitable for us, and which routes actually bled money.
Show code
import numpy as npimport pandas as pdimport plotly.express as pximport plotly.graph_objects as go# ── Toggle: include humanitarian events in route economics?include_emergency =False# ── Ensure route / trip_type available on the revenue rowsrev_base = revenue.copy()need_cols = {"trip_id", "route", "trip_type"}ifnot need_cols.issubset(rev_base.columns): rev_base = rev_base.merge( trips[["trip_id", "route", "trip_type"]], on="trip_id", how="left" )# ── Optional exclusion of emergency tripsifnot include_emergency and"trip_type"in rev_base.columns: rev_base = rev_base[rev_base["trip_type"].str.lower() !="emergency"]# ── Clean numeric columnsfor c in ["realized_revenue","profit"]:if c in rev_base.columns: rev_base[c] = pd.to_numeric(rev_base[c], errors="coerce")# ── Aggregate per route (weighted margin)route_perf = ( rev_base.groupby("route", as_index=False) .agg( trips=("trip_id","nunique"), realized_revenue=("realized_revenue","sum"), profit=("profit","sum") ))route_perf["profit_margin"] = np.where( route_perf["realized_revenue"] >0, route_perf["profit"] / route_perf["realized_revenue"], np.nan)# Keep routes with defined marginroute_perf = route_perf.dropna(subset=["profit_margin"]).copy()# Order routes for plottingroute_perf = route_perf.sort_values("profit_margin", ascending=True)# Color per sign (discrete, on-brand)route_perf["color"] = np.where(route_perf["profit_margin"] >=0, TRAWAY_COLORS["primary"], TRAWAY_COLORS["danger"])# ── Plotfig = px.bar( route_perf, x="profit_margin", y="route", orientation="h", labels={"profit_margin": "Average Profit Margin (%)", "route": "Route"}, hover_data={"trips": True, "profit_margin": ":.1%"},)# Apply discrete colors & formattingfig.update_traces(marker_color=route_perf["color"])fig.update_layout( title="Average Profit Margin by Route (Weighted; No INR)", xaxis_tickformat=".0%", xaxis=dict(zeroline=True, zerolinecolor=TRAWAY_COLORS["light"], zerolinewidth=2), plot_bgcolor="white", height=550, margin=dict(t=70, r=40, b=40, l=140), showlegend=False)fig.show()
The top three routes were commute based routes, and while next 4 routes were profitable too, The actual profits were really small because of the event size.
But enough about profits. The real question is — what drove those numbers? Let’s shift from margins to motion and look at how occupancy rates shaped every outcome that followed.
Decoding Occupancy — The Pulse of Every Trip
If profit was the scoreboard, occupancy was the heartbeat. It revealed how well our ideas translated into real seats filled — whether people believed enough to show up. Across commutes, recreationals, and even emergencies, occupancy became the truest indicator of trust, timing, and how well we understood demand in motion.
(Figure 6: Event occupancy and scale — showing how capacity utilization varied across different trip types.)
Occupancy tells the story behind every margin. Emergency trips, as expected, ran nearly full — driven by necessity, not promotion. Commute operations stayed impressively strong, hitting near-90% capacity even at scale. Recreational trips, meanwhile, dipped lower but served their purpose — smaller, more intimate experiences that kept the brand visible and human.
Show code
# Boxplot (leaner; use violin if you prefer spread emphasis)fig2 = px.box( trip_occ, x="trip_type", y="occupancy", color="trip_type", color_discrete_map=type_colors, points="outliers")fig2.update_traces(hovertemplate="Type: %{x}<br>Occupancy: %{y:.2%}<extra></extra>")fig2.update_layout( title="Occupancy by Trip Type", xaxis_title="Trip Type", yaxis_title="Occupancy (fraction)", plot_bgcolor="white", showlegend=False, margin=dict(l=40, r=40, t=70, b=60))fig2.update_yaxes(tickformat=".0%", showgrid=True, gridcolor=TRAWAY_COLORS["light"])fig2.show()
Charts:
(Figure 7: Occupancy distribution by trip type — commute trips showing operational discipline, recreationals revealing flexibility.)
This comparison shows how each category behaved under the same system. These boxplots gives a good visual idea of how the occupancy varied within each event type.
When Price Meets Demand — Fare vs. Occupancy
Before drawing conclusions from occupancy alone, we wanted to test a deeper question — does price influence how full a trip gets? In other words, are people booking because they need to travel, or because it simply feels worth it? This analysis compares how fare levels affected occupancy across our two main categories: commute and recreational trips.
Show code
import plotly.express as pximport plotly.graph_objects as gofrom scipy import statsimport numpy as np# ─────────────────────────────────────────────# Brand palette# ─────────────────────────────────────────────# Map trip types to brand colorstrip_type_colors = {"commute": TRAWAY_COLORS["primary"],"recreational": TRAWAY_COLORS["accent"],}# ─────────────────────────────────────────────# Data prep (your existing logic)# ─────────────────────────────────────────────emergency_trips = trips.loc[trips["trip_type"].str.lower() =="emergency", "trip_id"]occupied = ( bookings.loc[ (bookings["payment_status"].str.lower() !="refunded") & (~bookings["trip_id"].isin(emergency_trips)) ] .groupby("trip_id", as_index=False) .agg(filled_seats=("booking_id", "count")))trip_occ = ( trips.loc[~trips["trip_id"].isin(emergency_trips), ["trip_id", "total_seats", "trip_type", "route"]] .merge(occupied, on="trip_id", how="left"))trip_occ["filled_seats"] = trip_occ["filled_seats"].fillna(0).astype(int)trip_occ["occupancy_rate"] = trip_occ["filled_seats"] / trip_occ["total_seats"]bookings_paid = bookings.loc[ (bookings["payment_status"].str.lower() =="paid") & (~bookings["trip_id"].isin(emergency_trips))]bookings_by_trip = ( bookings_paid.groupby("trip_id", as_index=False)["fare_paid"].mean() .rename(columns={"fare_paid": "avg_fare"}))fare_occ = ( trip_occ.merge(bookings_by_trip, on="trip_id", how="inner") .query("total_seats > 0") .dropna(subset=["avg_fare"]))# ─────────────────────────────────────────────# Create base scatter plot with brand colors# ─────────────────────────────────────────────fig = px.scatter( fare_occ, x="occupancy_rate", y="avg_fare", color="trip_type", symbol="trip_type", color_discrete_map=trip_type_colors, hover_data={"trip_id": True,"route": True,"occupancy_rate": ":.0%","avg_fare": ":,.0f" }, labels={"occupancy_rate": "Occupancy Rate","avg_fare": "Average Fare (₹)","trip_type": "Trip Type" })# ─────────────────────────────────────────────# Add regression line for each trip type# ─────────────────────────────────────────────for trip_type in ["commute", "recreational"]:# Filter data for this trip type trip_data = fare_occ[fare_occ["trip_type"] == trip_type]iflen(trip_data) <2: # Need at least 2 points for regressioncontinue# Compute regression x = trip_data["occupancy_rate"].values y = trip_data["avg_fare"].values slope, intercept, r_value, p_value, std_err = stats.linregress(x, y) r_squared = r_value **2# Generate regression line points x_line = np.linspace(x.min(), x.max(), 100) y_line = slope * x_line + intercept# Add regression line fig.add_trace( go.Scatter( x=x_line, y=y_line, mode='lines', name=f'{trip_type.title()} Trend (R²={r_squared:.3f})', line=dict( color=trip_type_colors[trip_type], width=2.5, dash='dash' ), hovertemplate=(f"<b>{trip_type.title()} Regression</b><br>"f"R² = {r_squared:.3f}<br>"f"Slope = {slope:.2f}<br>"f"Intercept = {intercept:.2f}""<extra></extra>" ), showlegend=True ) )# ─────────────────────────────────────────────# Layout polish with brand styling# ─────────────────────────────────────────────fig.update_layout( title=dict( text="Average Fare vs Occupancy Rate<br><sup>Commute trips show reliability-driven demand; recreational trips show price sensitivity</sup>", x=0.5, xanchor='center' ), height=520, xaxis=dict( tickformat=".0%", title="Occupancy Rate", gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="Average Fare (₹)", gridcolor=TRAWAY_COLORS["light"] ), legend=dict( orientation="h", yanchor="bottom", y=-0.25, xanchor="center", x=0.5, title="Trip Type" ), margin=dict(t=90, b=100, l=80, r=40), plot_bgcolor="white", hovermode='closest')# Update scatter markersfig.update_traces( selector=dict(mode='markers'), marker=dict( size=8, line=dict(width=0.5, color="white"), opacity=0.7 ), hovertemplate="<b>%{customdata[1]}</b><br>Occupancy: %{x:.0%}<br>Avg Fare: ₹%{y:,.0f}<extra></extra>")fig.show()
(Figure 8: Average fare vs. occupancy rate — comparing price sensitivity across commute and recreational events.)
To quantify this relationship, we ran a simple regression between average fare and occupancy rate for both categories. The results told two contrasting stories. For commute trips, the R² value was barely 0.02 — meaning fare changes explained almost none of the variation in how full the buses got. People traveled because they had to, not because the price was right.
For recreational trips, the R² value climbed to around 0.13 — still modest, but strong enough to show a visible price sensitivity. When fares rose, attendance dropped slightly; when fares eased, more students signed up. That 0.13 didn’t just represent a number — it represented choice, flexibility, and the emotional difference between must-go and want-to-go travel.
In short, the regression confirmed what we’d long sensed: commute trips ran on necessity, and recreational trips ran on perception. Understanding that split, although intuitively at first, helped us design pricing strategies that respected both — steady reliability on one side, and accessible curiosity on the other.
The Psychology of Prebooking — Behavioral Insights
Predicting demand was one of Traway’s hardest puzzles. Most students booked their tickets in the final days before departure, which often left us with underfilled buses that could’ve easily run at full capacity. To fix this, we introduced a simple idea: prebooking — a small ₹100 discount for those who booked 21 days in advance. We expected a flood of early sign-ups. Instead, the response was… underwhelming.
The next season, we kept everything identical — same routes, same discount — but tweaked the message. Instead of saying “Book now and save ₹100”, we said “If you don’t prebook 21 days in advance, fares will be ₹100 higher.” That single sentence changed everything. Prebooking rates doubled. We didn’t realize it immediately, but we had stumbled into the world of loss aversion — the idea that people are more motivated to avoid losing something than to gain the same amount.
To confirm that this difference wasn’t just luck, we ran a two-proportion z-test comparing prebooking rates across both campaigns.
Hypotheses
Null Hypothesis (H₀): There is no significant difference in prebooking rates between gain-framed and loss-framed messages.
Alternative Hypothesis (H₁): Loss-framed messages lead to significantly higher prebooking rates than gain-framed messages.
Show code
import numpy as npimport pandas as pdimport plotly.graph_objects as gofrom statsmodels.stats.proportion import proportions_ztest# ─────────────────────────────────────────────# Filter valid experimental bookings# ─────────────────────────────────────────────exp_data = bookings.loc[ (bookings["payment_status"].str.lower() !="refunded") & (bookings["framing_type"].str.lower().isin(["gain", "loss"]))].copy()# ─────────────────────────────────────────────# Compute prebooking counts per frame# ─────────────────────────────────────────────summary = ( exp_data.groupby("framing_type", as_index=False) .agg( total_bookings=("booking_id", "count"), prebookings=("prebooking_flag", "sum") ))summary["prebook_rate"] = summary["prebookings"] / summary["total_bookings"]# ─────────────────────────────────────────────# Z-test for difference in proportions# ─────────────────────────────────────────────count = summary["prebookings"].valuesnobs = summary["total_bookings"].valuesz_stat, p_val = proportions_ztest(count, nobs, alternative="smaller") # gain < loss# Determine significancesig_text ="Significant"if p_val <0.05else"Not Significant"sig_color = TRAWAY_COLORS["accent"] if p_val <0.05else TRAWAY_COLORS["neutral"]print(f"Z-statistic = {z_stat:.3f}, p-value = {p_val:.4f}")print(f"Result: {sig_text} (α = 0.05)")# ─────────────────────────────────────────────# Create bar chart with brand colors# ─────────────────────────────────────────────# Map framing types to colorscolor_map = {"gain": TRAWAY_COLORS["primary"], # Deep blue"loss": TRAWAY_COLORS["accent"] # Gold (highlight effect)}fig = go.Figure()for idx, row in summary.iterrows(): frame = row["framing_type"] rate = row["prebook_rate"] fig.add_trace(go.Bar( x=[frame.title()], y=[rate], name=frame.title(), marker_color=color_map[frame], text=f"{rate:.1%}", textposition="outside", textfont=dict(size=14, color=color_map[frame]), hovertemplate=(f"<b>{frame.title()} Framing</b><br>"f"Prebooking Rate: {rate:.1%}<br>"f"Prebookings: {row['prebookings']:,} / {row['total_bookings']:,}""<extra></extra>" ), showlegend=False ))# ─────────────────────────────────────────────# Add statistical annotation# ─────────────────────────────────────────────fig.add_annotation( x=0.5, y=max(summary["prebook_rate"]) *1.15, xref="paper", yref="y", text=f"<b>{sig_text}</b> (p = {p_val:.4f}, z = {z_stat:.2f})", showarrow=False, font=dict(size=12, color=sig_color), bgcolor="white", bordercolor=sig_color, borderwidth=2, borderpad=4)# ─────────────────────────────────────────────# Layout polish with brand styling# ─────────────────────────────────────────────fig.update_layout( title=dict( text="Prebooking Participation by Framing Type<br><sup>Loss framing ('Book Now or Pay More Later!') vs gain framing ('Book now and save!')</sup>", x=0.5, xanchor='center' ), xaxis=dict( title="Framing Type", gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="Prebooking Rate", tickformat=".0%", gridcolor=TRAWAY_COLORS["light"],range=[0, max(summary["prebook_rate"]) *1.25] # Add headroom for text ), height=450, plot_bgcolor="white", margin=dict(t=90, b=60, l=60, r=40))fig.show()# ─────────────────────────────────────────────# Print summary table for reference# ─────────────────────────────────────────────print("\n"+"="*50)print("SUMMARY TABLE")print("="*50)print(summary.round(3).to_string(index=False))
Figure 1: Prebooking rates by message framing type
==================================================
SUMMARY TABLE
==================================================
framing_type total_bookings prebookings prebook_rate
gain 1208 325 0.26904
loss 635 338 0.532283
(Figure 9: Prebooking participation rates under two message frames — gain framing (“Book now and save”) vs. loss framing (“Only X seats left / fares will rise”).)
The results were crystal clear — z = -11.19, p < 0.001, statistically significant well beyond the 0.05 threshold. Prebooking participation jumped from 26.9% under gain framing to 53.2% under loss framing.
In essence, for the first time in our lives, we learned, by practical implementation, that people respond more strongly to avoiding loss than pursuing gain — a behavioral truth straight from psychology textbooks, but validated through a an attempt to reduce unoccupied seats in Srinagar. That moment was more than an operational tweak; it was our first brush with behavioral economics in motion.
The Race Against the Clock — Understanding Operational Delays
In transport, time isn’t just a metric it’s a promise. Every delay ripples through satisfaction, perception, and even future bookings. So, to understand how efficiently Traway performed in real-world conditions, we analyzed departure delays across trip types, on-time performance by event, and the overall delay distribution across all operations.
(Figure 10: Overall trip delay distribution — density of departure times across all events.)
Across the entire dataset, the average delay settled around 15 minutes, with nearly 45% of trips running within an acceptable 15-minute window. Most delays were moderate rather than severe, showing our operational resilience even under variable conditions.
Show code
import numpy as npimport pandas as pdimport plotly.express as pximport plotly.graph_objects as gotype_colors = {"commute": TRAWAY_COLORS["primary"],"recreational": TRAWAY_COLORS["accent"],# "emergency": TRAWAY_COLORS["danger"],}# 1) Keep only trips that actually rantrips_nc = trips.loc[trips["cancellation_flag"] ==False].copy()# 2) Basic columns & guardsneed = ["trip_id","event_batch_id","trip_type","delay_min","total_seats"]missing = [c for c in need if c notin trips_nc.columns]if missing:raiseValueError(f"Missing columns in trips: {missing}")# 3) Derive on-time boolean (≤10 minutes late)trips_nc["on_time"] = trips_nc["delay_min"].le(10)# 4) Event-level on-time rateevent_ontime = ( trips_nc.groupby("event_batch_id", as_index=False) .agg( trips=("trip_id","count"), ontime_rate=("on_time","mean") ))# 5) Event trip_type (mode)event_type = ( trips_nc.groupby("event_batch_id")["trip_type"] .agg(lambda s: s.mode().iat[0] iflen(s.mode()) else s.iloc[0]) .reset_index() .rename(columns={"trip_type":"event_trip_type"}))event_ontime = event_ontime.merge(event_type, on="event_batch_id", how="left")event_ontime["ontime_pct"] = (event_ontime["ontime_rate"]*100).round(1)# Convenience sort order for bar chartevent_order = event_ontime.sort_values("ontime_rate", ascending=False)["event_batch_id"].tolist()plot_df = trips_nc.loc[trips_nc["trip_type"].ne("emergency")].copy()fig = px.violin( plot_df, x="trip_type", y="delay_min", color="trip_type", color_discrete_map={k: v for k, v in type_colors.items() if k in ["commute","recreational"]}, box=True, points="outliers", category_orders={"trip_type": ["commute", "recreational"]},)fig.update_layout( title="Departure Delays by Trip Type (minutes)", xaxis_title="Trip Type", yaxis_title="Delay (min)", plot_bgcolor="white", showlegend=False, margin=dict(l=40, r=40, t=70, b=60))fig.update_yaxes(showgrid=True, gridcolor=TRAWAY_COLORS["light"], rangemode="tozero")fig.show()
(Figure 11: Departure delays by trip type — comparing consistency across commute, recreational, and emergency trips.)
Commute trips showed the widest spread of delays, reflecting the sheer operational scale and dependency on multiple campus schedules. Recreational trips performed slightly better — smaller groups, flexible timings, and fewer variables.
Show code
# Filter out emergency eventsevent_ontime_no_emerg = event_ontime.loc[event_ontime["event_trip_type"] !="emergency"].copy()# Update event order accordinglyevent_order_no_emerg = ( event_ontime_no_emerg.sort_values("ontime_rate", ascending=False)["event_batch_id"].tolist())# Build colors for non-emergency events onlybar_colors = [ type_colors.get(t, TRAWAY_COLORS["neutral"])for t in event_ontime_no_emerg.set_index("event_batch_id").loc[event_order_no_emerg]["event_trip_type"]]# Create bar chartfig2 = go.Figure( go.Bar( x=event_order_no_emerg, y=event_ontime_no_emerg.set_index("event_batch_id").loc[event_order_no_emerg]["ontime_pct"], marker_color=bar_colors, hovertemplate="<b>%{x}</b><br>On-time: %{y:.1f}%<extra></extra>", name="On-time (%)", ))fig2.update_layout( title="On-time Rate by Event (delay ≤ 10 minutes)", xaxis=dict(title="Event Batch", tickangle=-30), yaxis=dict(title="On-time (%)", rangemode="tozero"), plot_bgcolor="white", margin=dict(l=40, r=40, t=70, b=80), showlegend=False)fig2.update_yaxes(showgrid=True, gridcolor=TRAWAY_COLORS["light"])fig2.show()
(Figure 12: On-time rate by event — proportion of trips departing within 10 minutes of schedule.)
When examined event by event, the pattern sharpens. Recreational trips led the chart in punctuality, maintaining over 40% on-time rates, followed closely by the December 2023 emergency batch. Commutes, while reliable in execution, often saw slower departures due to the natural lag of student coordination, because usually when students went home they travelled with a whole lot of luggage. That was tradeoff between scale and punctuality that we consciously accepted.
In short, these analyses reveal that while perfection in timing was elusive, predictability was our real strength. Students trusted that if Traway said a trip would happen, it would.
From Delays to Decisions — The Refund Connection
Delays don’t exist in isolation; they shape perception, trust, and sometimes, people’s wallets. The can quietly kill your brand. After mapping how punctual (or not) our trips were, the next logical step was to see whether these delays had a tangible consequence — did they affect refund rates? In other words, when the clock slipped, did confidence slip with it?
By correlating refund frequency with delay durations, we wanted to uncover whether timing issues actually triggered cancellations or if customers were more forgiving than we assumed. This section dives into that intersection — where operations meet human patience, and where minutes late can quietly turn into lost revenue or loyalty.
Let’s first visualize refund rates by event to see whether any pattern emerges.
Show code
import plotly.graph_objects as goimport numpy as np# ─────────────────────────────────────────────# Prepare data (optional: exclude emergency)# ─────────────────────────────────────────────include_emergency =Trueev_view = revenue_event.copy()ifnot include_emergency: ev_view = ev_view[ev_view["event_batch_id"] !="EV-2023-DEC-EMERGENCY"]# Sort by refund_rate descending (highest first)ev_sorted = ev_view.sort_values("refund_rate", ascending=True).reset_index(drop=True)# ─────────────────────────────────────────────# Create color gradient using brand colors# ─────────────────────────────────────────────# Create gradient from accent (gold) for high refunds to primary (blue) for low refundsdef create_color_gradient(values, color_high, color_low):"""Create color gradient based on values"""# Normalize values to 0-1 range normalized = (values - values.min()) / (values.max() - values.min() +1e-10)# Convert hex to RGBdef hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#')returntuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) rgb_high = hex_to_rgb(color_high) rgb_low = hex_to_rgb(color_low)# Interpolate colors colors = []for norm_val in normalized: r =int(rgb_high[0] * norm_val + rgb_low[0] * (1- norm_val)) g =int(rgb_high[1] * norm_val + rgb_low[1] * (1- norm_val)) b =int(rgb_high[2] * norm_val + rgb_low[2] * (1- norm_val)) colors.append(f'rgb({r},{g},{b})')return colors# Generate colors (gold for high refunds, blue for low)bar_colors = create_color_gradient( ev_sorted["refund_rate"].values, TRAWAY_COLORS["accent"], # Gold for high refunds (problem) TRAWAY_COLORS["primary"] # Blue for low refunds (good))# ─────────────────────────────────────────────# Create horizontal bar chart# ─────────────────────────────────────────────fig = go.Figure()fig.add_trace(go.Bar( x=ev_sorted["refund_rate"], y=ev_sorted["event_batch_id"], orientation='h', marker=dict( color=bar_colors, line=dict(color='white', width=0.5) ), text=ev_sorted["refund_rate"].map(lambda v: f"{v:.1%}"), textposition="auto", textfont=dict(size=11), customdata=np.column_stack(( ev_sorted["bookings_refunded"], ev_sorted["bookings_paid"], ev_sorted["refund_rate"] )), hovertemplate=("<b>%{y}</b><br>""Refund Rate: %{customdata[2]:.1%}<br>""Refunded: %{customdata[0]:,}<br>""Paid: %{customdata[1]:,}""<extra></extra>" )))# ─────────────────────────────────────────────# Layout with brand styling# ─────────────────────────────────────────────avg_refund = ev_sorted["refund_rate"].mean()max_refund = ev_sorted["refund_rate"].max()min_refund = ev_sorted["refund_rate"].min()fig.update_layout( title=dict( text=f"Refund Rate by Event (Descending Order)<br><sup>Average: {avg_refund:.1%} | Range: {min_refund:.1%} - {max_refund:.1%}</sup>", x=0.5, xanchor='center' ), xaxis=dict( title="Refund Rate", tickformat=".0%", gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="Event Batch", categoryorder='array', categoryarray=ev_sorted["event_batch_id"].tolist() # Ensure top-to-bottom order ), height=max(520, len(ev_sorted) *25), # Dynamic height based on number of events plot_bgcolor="white", margin=dict(t=90, r=80, b=60, l=160), showlegend=False)# Add reference line for average refund ratefig.add_vline( x=avg_refund, line_dash="dot", line_color=TRAWAY_COLORS["neutral"], line_width=2, opacity=0.6, annotation_text=f"Avg: {avg_refund:.1%}", annotation_position="top right", annotation_font=dict(size=10, color=TRAWAY_COLORS["neutral"]))fig.show()# ─────────────────────────────────────────────# Print summary statistics# ─────────────────────────────────────────────print("\n"+"="*60)print("REFUND RATE SUMMARY")print("="*60)print(f"Average refund rate: {avg_refund:.2%}")print(f"Highest refund rate: {max_refund:.2%} ({ev_sorted.iloc[-1]['event_batch_id']})")# print(f"Lowest refund rate: {min_refund:.2%} ({ev_sorted.iloc[1]['event_batch_id']})")print(f"Events analyzed: {len(ev_sorted)}")print("="*60)
Figure 3: Refund rates by event batch, ordered from highest to lowest
(Figure 13: Refund rate by event — visualizing refund frequency across all major event batches.)
While our refund numbers weren’t that high, Most refunds clustered between 2–3%, with only a few outliers on either end. The December 2022 Commute event showed the highest refund rate at 3.9%, while later commute operations (like July and October 2023) dropped significantly below average — suggesting improved reliability and communication systems over time.
Next, we examined whether longer delays actually caused higher refund rates. At first glance, one might expect a clear upward trend — more delay, more frustration, more refunds. But the data told a calmer story. The correlation turned out to be weak and statistically insignificant (R² = 0.006, p = 0.8425), meaning refund behavior was largely independent of operational delays.
Commute events, represented by larger blue bubbles, showed varied delay times but no consistent rise in refunds. Recreational trips clustered tightly around the 2–3% mark regardless of timing performance. Even events exceeding the 15-minute SLA threshold didn’t see noticeable spikes in refund activity.
This revealed something subtle yet powerful — trust had inertia. Passengers didn’t ask for refund because of delays; they canceled when uncertainty or communication failed. Fortunately our transparent updates and reliability over time built enough goodwill that minor delays didn’t translate into financial losses.
Measuring What Matters — Service Quality and Satisfaction
Operational efficiency only tells half the story. The real success of Traway was defined not just by buses that ran, but by how people felt while riding them. In this section, we explored service quality through the lens of customer satisfaction — how ratings varied across trip types, how delays influenced those ratings, and whether spending more on onboard experience actually translated into happier passengers.
Let’s begin examining the distribution of feedback scores across both major trip types. The overall satisfaction averaged 4.18 out of 5, with a median of 4.3 — a strong indicator of trust and consistency. Recreational trips scored slightly higher (mean 4.67) than commutes (mean 4.00), likely reflecting the relaxed context of leisure travel versus the stress and punctuality demands of end of the semester transit.
============================================================
SATISFACTION SUMMARY
============================================================
trip_type mean median count std
commute 4.00 4.0 1208 0.62
recreational 4.67 4.8 440 0.39
Overall Mean Rating: 4.18
Overall Median Rating: 4.30
Total Feedback: 1,648
============================================================
Figure 5: Customer satisfaction ratings by trip type
The takeaway was clear: commutes tested reliability, recreationals rewarded delight. Both performed well, but in different emotional territories — one transactional, one experiential.
Next, let’s see whether delays tangibly influenced satisfaction. The results showed a weak but statistically significant negative relationship (R² = 0.013, p < 0.001). In practical terms, every 10-minute delay translated to roughly a 0.11-point drop in average rating. That’s small on paper but meaningful in pattern — it confirmed that timeliness mattered, even if passengers were forgiving up to a point.
Figure 6: Impact of operational delays on customer satisfaction
(Figure 15: Operational reliability vs. customer satisfaction — analyzing how delays impacted feedback ratings.)
Analytically, this tells us that our communication and reliability buffer kept mild delays from turning into dissatisfaction, but consistent punctuality still paid dividends in perception. In short, people didn’t punish the occasional late bus — they remembered the pattern.
Finally, we explored whether investing more in onboard refreshments for recreational trips produced a measurable effect on satisfaction during recreational trips. Despite intuitive expectations, the correlation turned out statistically insignificant (R² = 0.006, p ≈ 0.11). A ₹10 increase in per-student refreshment spend yielded only about +0.018 rating points on average.
Show code
import numpy as npimport pandas as pdimport plotly.graph_objects as gofrom scipy import stats# ─────────────────────────────────────────────# Prepare recreational trip data# ─────────────────────────────────────────────# Filter recreational trips onlyrec_trips = trips.loc[trips["trip_type"].str.lower() =="recreational", ["trip_id", "route"]].copy()# Join feedback with recreational tripsrec_fb = ( feedback[["trip_id", "rating"]] .merge(rec_trips, on="trip_id", how="inner"))# Bring in costs (refreshment_cost is trip-level total)rec_fb = rec_fb.merge( costs[["trip_id", "refreshment_cost"]], on="trip_id", how="left")# Bring in realized_bookings for per-student calculation# Use the revenue dataframe loaded from revenue.csvrec_fb = rec_fb.merge( revenue[["trip_id", "realized_bookings"]], on="trip_id", how="left")# Compute per-student refreshment spend (guard against zero/NaN)rec_fb["per_student_refreshment"] = np.where( rec_fb["realized_bookings"].fillna(0) >0, rec_fb["refreshment_cost"].fillna(0) / rec_fb["realized_bookings"].fillna(0), np.nan)# Keep valid rowsrec_fb = rec_fb.dropna(subset=["rating", "per_student_refreshment"])# Check if we have dataiflen(rec_fb) ==0:print("ERROR: No recreational trip data found with valid refreshment costs and ratings")print("\nDebugging info:")print(f"Recreational trips in feedback: {len(feedback.merge(rec_trips, on='trip_id'))}")print(f"With refreshment costs: {len(feedback.merge(rec_trips, on='trip_id').merge(costs, on='trip_id'))}")else:# ─────────────────────────────────────────────# Statistical analysis# ───────────────────────────────────────────── x = rec_fb["per_student_refreshment"].values y = rec_fb["rating"].values# Linear regression slope, intercept, r_value, p_value, std_err = stats.linregress(x, y) r_squared = r_value **2 correlation = np.corrcoef(x, y)[0, 1]print("="*60)print("REFRESHMENT SPEND → SATISFACTION ANALYSIS")print("="*60)print(f"Sample size: {len(rec_fb)} recreational trip feedbacks")print(f"Spend range: ₹{x.min():.1f} - ₹{x.max():.1f} per student")print(f"Correlation: {correlation:.3f}")print(f"R² = {r_squared:.3f}")print(f"p-value = {p_value:.6f}")print(f"Slope = {slope:.4f} (rating change per ₹ spent)")print(f"Effect: ₹10 extra → {slope*10:.3f} rating points")if p_value <0.05:print("✓ Statistically significant relationship")else:print("✗ No significant relationship (p ≥ 0.05)")print("="*60)# ─────────────────────────────────────────────# Create scatter plot with brand colors# ─────────────────────────────────────────────# Create color gradient based on spending (low=blue, high=gold)def create_spend_colors(values):"""Map spending values to brand color gradient""" normalized = (values - values.min()) / (values.max() - values.min() +1e-10)def hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#')returntuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) rgb_low = hex_to_rgb(TRAWAY_COLORS["primary"]) # Blue for low spend rgb_high = hex_to_rgb(TRAWAY_COLORS["accent"]) # Gold for high spend colors = []for norm_val in normalized: r =int(rgb_low[0] * (1- norm_val) + rgb_high[0] * norm_val) g =int(rgb_low[1] * (1- norm_val) + rgb_high[1] * norm_val) b =int(rgb_low[2] * (1- norm_val) + rgb_high[2] * norm_val) colors.append(f'rgb({r},{g},{b})')return colors marker_colors = create_spend_colors(rec_fb["per_student_refreshment"].values) fig = go.Figure()# Add scatter points fig.add_trace(go.Scatter( x=rec_fb["per_student_refreshment"], y=rec_fb["rating"], mode='markers', name="Recreational Trips", marker=dict( size=8, color=marker_colors, opacity=0.6, line=dict(width=0.5, color="white") ), customdata=np.column_stack(( rec_fb["trip_id"], rec_fb["route"], rec_fb["realized_bookings"], rec_fb["per_student_refreshment"], rec_fb["rating"] )), hovertemplate=("<b>%{customdata[1]}</b><br>""Trip ID: %{customdata[0]}<br>""Students: %{customdata[2]:.0f}<br>""Spend/Student: ₹%{customdata[3]:.1f}<br>""Rating: %{customdata[4]:.2f}/5.0""<extra></extra>" ), showlegend=False ))# ─────────────────────────────────────────────# Add regression line# ───────────────────────────────────────────── x_line = np.linspace(x.min(), x.max(), 100) y_line = slope * x_line + intercept fig.add_trace(go.Scatter( x=x_line, y=y_line, mode='lines', name=f'Trend (R²={r_squared:.3f})', line=dict( color=TRAWAY_COLORS["neutral"], width=3, dash='dash' ), hovertemplate=(f"<b>Linear Regression</b><br>"f"R² = {r_squared:.3f}<br>"f"Correlation = {correlation:.3f}<br>"f"p-value = {p_value:.6f}<br>"f"Effect: ₹10 → {slope*10:.3f} ★""<extra></extra>" ), showlegend=False ))# ─────────────────────────────────────────────# Layout with brand styling# ─────────────────────────────────────────────# Calculate median spend as reference median_spend = rec_fb["per_student_refreshment"].median() fig.update_layout( title=dict( text=f"Refreshment Investment vs Customer Satisfaction<br><sup>Recreational trips only | Correlation: {correlation:.3f} | ₹10 extra spend ≈ {slope*10:.3f} rating points</sup>", x=0.5, xanchor='center' ), xaxis=dict( title="Refreshment Spend per Student (₹)", gridcolor=TRAWAY_COLORS["light"], zeroline=False ), yaxis=dict( title="Customer Rating (out of 5.0)", gridcolor=TRAWAY_COLORS["light"],range=[1, 5.3] ), height=520, plot_bgcolor="white", margin=dict(t=90, b=70, l=70, r=40) )# Add median spend reference line fig.add_vline( x=median_spend, line_dash="dot", line_color=TRAWAY_COLORS["neutral"], line_width=1.5, opacity=0.4, annotation_text=f"Median: ₹{median_spend:.0f}", annotation_position="top", annotation_font=dict(size=9, color=TRAWAY_COLORS["neutral"]) )# Add statistical annotation sig_color = TRAWAY_COLORS["accent"] if p_value <0.05and correlation >0.2else TRAWAY_COLORS["neutral"] fig.add_annotation( x=0.02, y=0.98, xref="paper", yref="paper", text=(f"<b>{'Significant'if p_value <0.05else'Weak'} Effect</b><br>"f"R² = {r_squared:.3f}<br>"f"p = {p_value:.6f}<br>"f"n = {len(rec_fb)} trips" ), showarrow=False, font=dict(size=10, color=sig_color), bgcolor="white", bordercolor=sig_color, borderwidth=2, borderpad=6, align="left", xanchor="left", yanchor="top" )# Add color legend annotation fig.add_annotation( x=0.98, y=0.02, xref="paper", yref="paper", text=("<b>Color Scale</b><br>"f"<span style='color:{TRAWAY_COLORS['primary']}'>●</span> Low spend<br>"f"<span style='color:{TRAWAY_COLORS['accent']}'>●</span> High spend" ), showarrow=False, font=dict(size=9, color=TRAWAY_COLORS["neutral"]), bgcolor="white", bordercolor=TRAWAY_COLORS["light"], borderwidth=1.5, borderpad=5, align="left", xanchor="right", yanchor="bottom" ) fig.show()
Figure 7: Impact of refreshment spending on customer satisfaction (recreational trips)
This revealed an interesting behavioral nuance — passengers valued clarity, coordination, and comfort far more than material extras. In other words, a smoother recreational trip experience outweighed an extra snack.
Together, these analyses painted a full picture of what quality meant for Traway: not luxury, but reliability; not extravagance, but empathy in motion. People remembered how we managed the journey, not just what we served along the way.
Loyalty & Retention — The Proof of Trust
In an age where attention spans are shrinking and loyalty is fleeting, we were fortunate to build something that people kept coming back to, again … and again. What began as a one-time student transport solution slowly evolved into a trusted companion for every semester, trip, and return home. This section looks at how loyalty formed, grew, and concentrated over time — the story of retention told through data.
Over 60% of our 3,172 active customers were repeat travelers — a remarkable retention rate for a student-led operation. The median traveler took two trips, with some completing as many as 11.
This wasn’t driven by discounts or gimmicks — it was built on reliability and emotional equity. Every return trip was a small vote of confidence that what we built worked — not once, but repeatedly.
Show code
import plotly.graph_objects as goimport numpy as npimport pandas as pd# ─────────────────────────────────────────────# Prepare student booking frequency data# ─────────────────────────────────────────────# Count trips per student from bookings (only paid, non-refunded)paid_bookings = bookings[bookings["payment_status"].str.lower() =="paid"].copy()student_trips = ( paid_bookings.groupby("student_id")["trip_id"] .nunique() .reset_index() .rename(columns={"trip_id": "trips_taken"}))# Calculate key metricstotal_students =len(student_trips)one_time_customers = (student_trips["trips_taken"] ==1).sum()repeat_customers = (student_trips["trips_taken"] >1).sum()repeat_rate = repeat_customers / total_students *100median_trips = student_trips["trips_taken"].median()mean_trips = student_trips["trips_taken"].mean()print("="*60)print("CUSTOMER LOYALTY METRICS")print("="*60)print(f"Total active customers: {total_students:,}")print(f"One-time customers: {one_time_customers:,} ({one_time_customers/total_students*100:.1f}%)")print(f"Repeat customers: {repeat_customers:,} ({repeat_rate:.1f}%)")print(f"Median trips per customer: {median_trips:.0f}")print(f"Mean trips per customer: {mean_trips:.2f}")print(f"Max trips by single customer: {student_trips['trips_taken'].max()}")print("="*60)# ─────────────────────────────────────────────# Create histogram with brand colors# ─────────────────────────────────────────────# Cap at reasonable max for visualization (group high-frequency as "5+")max_display =5student_trips["trips_display"] = student_trips["trips_taken"].clip(upper=max_display)trip_counts = student_trips["trips_display"].value_counts().sort_index()# Create labelsx_labels = [f"{int(i)}"if i < max_display elsef"{int(i)}+"for i in trip_counts.index]# Color gradient: Blue for 1 trip, Gold for loyal customerscolors = []for i, count inenumerate(trip_counts.index):if count ==1: colors.append(TRAWAY_COLORS["neutral"]) # Gray for one-timeelif count <=2: colors.append(TRAWAY_COLORS["primary"]) # Blueelse: colors.append(TRAWAY_COLORS["accent"]) # Gold for loyalfig = go.Figure()fig.add_trace(go.Bar( x=x_labels, y=trip_counts.values, marker=dict( color=colors, line=dict(color="white", width=1) ), text=trip_counts.values, textposition="outside", texttemplate='<b>%{text:,}</b><br>(%{customdata:.1f}%)', customdata=(trip_counts.values / total_students *100), hovertemplate=("<b>%{x} Trips</b><br>""Students: %{y:,}<br>""Share: %{customdata:.1f}%""<extra></extra>" )))# ─────────────────────────────────────────────# Add repeat rate annotation# ─────────────────────────────────────────────fig.add_annotation( x=0.98, y=0.98, xref="paper", yref="paper", text=(f"<b>Repeat Rate: {repeat_rate:.1f}%</b><br>"f"{repeat_customers:,} of {total_students:,} customers<br>"f"Median trips: {median_trips:.0f}" ), showarrow=False, font=dict(size=11, color=TRAWAY_COLORS["accent"]), bgcolor="white", bordercolor=TRAWAY_COLORS["accent"], borderwidth=2, borderpad=8, align="left", xanchor="right", yanchor="top")# ─────────────────────────────────────────────# Layout# ─────────────────────────────────────────────fig.update_layout( title=dict( text=f"Customer Trip Frequency Distribution<br><sup>{repeat_rate:.1f}% of customers are repeat travelers | Trust measured in return trips</sup>", x=0.5, xanchor='center' ), xaxis=dict( title="Number of Trips Taken", gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="Number of Students", gridcolor=TRAWAY_COLORS["light"] ), height=500, plot_bgcolor="white", margin=dict(t=90, b=70, l=70, r=40), showlegend=False)fig.show()
============================================================
CUSTOMER LOYALTY METRICS
============================================================
Total active customers: 3,172
One-time customers: 1,249 (39.4%)
Repeat customers: 1,923 (60.6%)
Median trips per customer: 2
Mean trips per customer: 2.18
Max trips by single customer: 11
============================================================
Figure 8: Distribution of customer trip frequency
(Figure 16: Customer trip frequency distribution — measuring how often students returned to travel with Traway.)
Digging deeper, the Pareto pattern emerged: the top 20% of customers contributed ~45% of total revenue, and the top half drove nearly 80%. These were our core believers — students who didn’t just travel with Traway, but planned their semesters around it. The data underscored a business truth that felt deeply human: loyalty wasn’t widespread, but where it existed, it was deep.
============================================================
PARETO ANALYSIS - REVENUE CONCENTRATION
============================================================
Top 20% of customers → 44.6% of revenue
Top 50% of customers → 79.0% of revenue
Total revenue analyzed: ₹4,940,855
============================================================
Figure 9: Revenue concentration across customer base (Pareto analysis)
(Figure 17: Customer revenue concentration (Pareto curve) — identifying where loyalty translated to impact.)
Finally, when we tracked customers across multiple events, the progression became even clearer. Nearly half of first-time riders (49.5%) returned for a second trip, one in five (19.5%) became recurring users, and a devoted 2.7% turned into super-fans, joining us for five or more events. For a company born out of a dorm room, those numbers meant more than any financial graph could capture — they represented trust earned through consistency.
Show code
import plotly.graph_objects as goimport numpy as npimport pandas as pd# ─────────────────────────────────────────────# Build event sequence for each student# ─────────────────────────────────────────────# Get all paid bookings with eventsstudent_events = ( bookings[bookings["payment_status"].str.lower() =="paid"] .merge(trips[["trip_id", "event_batch_id", "trip_date"]], on="trip_id") .sort_values(["student_id", "trip_date"]))# Remove emergency eventstudent_events = student_events[ student_events["event_batch_id"] !="EV-2023-DEC-EMERGENCY"]# Number each event for each studentstudent_events["event_number"] = student_events.groupby("student_id").cumcount()# Pivot to get event progressionmax_events =5# Track up to 5 eventsevent_progression = student_events[student_events["event_number"] < max_events].copy()# Count students at each event stageretention_by_event = []for event_num inrange(max_events): students_at_event = event_progression[ event_progression["event_number"] == event_num ]["student_id"].nunique() retention_by_event.append({"event_number": event_num,"students": students_at_event,"retention_pct": 100if event_num ==0else (students_at_event / retention_by_event[0]["students"] *100) })retention_df = pd.DataFrame(retention_by_event)# Replace the print statement in Chart 3B with this:print("\n"+"="*60)print("MULTI-EVENT LOYALTY JOURNEY")print("="*60)print("\nThis analysis tracks individual customer progression:")print("- Event 1: Student's first booking (acquisition)")print("- Event 2: Student's second booking (first repeat)")print("- Event 3: Student's third booking (developing loyalty)")print("- Event 4+: Super-loyal customers (habitual users)")print("\n"+"-"*60)print(retention_df.to_string(index=False))print("="*60)print(f"\nKey Insight: {retention_df.iloc[1]['retention_pct']:.1f}% of first-time customers return for Event 2")iflen(retention_df) >2:print(f" {retention_df.iloc[2]['retention_pct']:.1f}% progress to Event 3 (loyal customers)")iflen(retention_df) >4:print(f" {retention_df.iloc[4]['retention_pct']:.1f}% become super-fans (5+ events)")print("="*60)# ─────────────────────────────────────────────# Create funnel visualization# ─────────────────────────────────────────────fig = go.Figure()# Create funnel barscolors_gradient = []for i inrange(len(retention_df)):# Gradient from blue to gold ratio = i /max(len(retention_df) -1, 1)if ratio <0.5: colors_gradient.append(TRAWAY_COLORS["primary"])else: colors_gradient.append(TRAWAY_COLORS["accent"])fig.add_trace(go.Bar( x=retention_df["event_number"], y=retention_df["students"], marker=dict( color=colors_gradient, opacity=0.8, line=dict(color="white", width=1.5) ), text=[f"<b>{row['students']:,}</b><br>({row['retention_pct']:.1f}%)"for _, row in retention_df.iterrows() ], textposition="outside", hovertemplate=("<b>Event %{x}</b><br>""Students: %{y:,}<br>""Retention: %{customdata:.1f}%""<extra></extra>" ), customdata=retention_df["retention_pct"]))# ─────────────────────────────────────────────# Add retention curve overlay# ─────────────────────────────────────────────fig.add_trace(go.Scatter( x=retention_df["event_number"], y=retention_df["retention_pct"], mode='lines+markers', name='Retention %', yaxis="y2", line=dict(color=TRAWAY_COLORS["neutral"], width=3, dash='dash'), marker=dict(size=10, color=TRAWAY_COLORS["neutral"]), hovertemplate="Retention: %{y:.1f}%<extra></extra>"))# ─────────────────────────────────────────────# Layout with dual axes# ─────────────────────────────────────────────fig.update_layout( title=dict( text="Customer Loyalty Progression<br><sup>Tracking individual students from first booking through repeat purchases | Event N = Nth booking in customer journey</sup>", x=0.5, xanchor='center' ),# ... rest of layout xaxis=dict( title="Event Number (Sequential)", tickmode='array', tickvals=retention_df["event_number"], ticktext=[f"Event {i+1}"for i in retention_df["event_number"]], gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="Number of Students", gridcolor=TRAWAY_COLORS["light"] ), yaxis2=dict( title="Retention Rate (%)", overlaying="y", side="right",range=[0, 110] ), height=520, plot_bgcolor="white", margin=dict(t=90, b=70, l=70, r=80), showlegend=False)fig.show()
============================================================
MULTI-EVENT LOYALTY JOURNEY
============================================================
This analysis tracks individual customer progression:
- Event 1: Student's first booking (acquisition)
- Event 2: Student's second booking (first repeat)
- Event 3: Student's third booking (developing loyalty)
- Event 4+: Super-loyal customers (habitual users)
------------------------------------------------------------
event_number students retention_pct
0 2716 100.000000
1 1344 49.484536
2 530 19.513991
3 209 7.695140
4 73 2.687776
============================================================
Key Insight: 49.5% of first-time customers return for Event 2
19.5% progress to Event 3 (loyal customers)
2.7% become super-fans (5+ events)
============================================================
Behind every bar in these charts was a name, a story, a student who decided to travel with us again — and in doing so, quietly validated that what we built mattered. In a business defined by buses and schedules, our real product was trust.
Loyalty in Detail — Breaking It Down by Year and Branch
After looking at overall loyalty and retention, we decided to dig a little deeper — partly out of curiosity, partly for fun. We wanted to see whether loyalty varied across student batches and academic branches. What started as an analytical detour quickly turned into something personal — patterns that reflected the social circles, friendships, and communication loops that quietly powered Traway from the inside.
Show code
import plotly.graph_objects as goimport numpy as npimport pandas as pdimport re# ─────────────────────────────────────────────# Extract enrollment year from enrollment_no# ─────────────────────────────────────────────def extract_enrollment_year(enrollment_no):"""Extract year from enrollment number like '2021BMEC005' → 2021"""if pd.isna(enrollment_no):returnNone match = re.match(r'(\d{4})', str(enrollment_no))returnint(match.group(1)) if match elseNonestudents_analysis = students.copy()students_analysis["enrollment_year"] = students_analysis["enrollment_no"].apply(extract_enrollment_year)students_analysis = students_analysis[students_analysis["enrollment_year"].notna()].copy()# ─────────────────────────────────────────────# Calculate loyalty metrics by year only# ─────────────────────────────────────────────students_analysis["is_repeat"] = students_analysis["trip_count"] >1year_loyalty = ( students_analysis.groupby("enrollment_year") .agg( total_students=("student_id", "count"), repeat_customers=("is_repeat", "sum"), total_trips=("trip_count", "sum"), avg_trips=("trip_count", "mean"), median_trips=("trip_count", "median") ) .reset_index())year_loyalty["repeat_rate"] = ( year_loyalty["repeat_customers"] / year_loyalty["total_students"] *100)# Filter for main batches (exclude outliers with <50 students)year_loyalty = year_loyalty[year_loyalty["total_students"] >=50].copy()year_loyalty = year_loyalty.sort_values("enrollment_year")print("="*60)print("LOYALTY BY ENROLLMENT YEAR")print("="*60)# print(year_loyalty.to_string(index=False))print("\n")# Identify best and worstbest_year = year_loyalty.loc[year_loyalty["repeat_rate"].idxmax()]worst_year = year_loyalty.loc[year_loyalty["repeat_rate"].idxmin()]overall_repeat = students_analysis["is_repeat"].sum() /len(students_analysis) *100print(f"Best performing batch: {int(best_year['enrollment_year'])} ({best_year['repeat_rate']:.1f}%)")print(f"Worst performing batch: {int(worst_year['enrollment_year'])} ({worst_year['repeat_rate']:.1f}%)")print(f"Overall repeat rate: {overall_repeat:.1f}%")print("="*60)# ─────────────────────────────────────────────# Create color gradient based on performance# ─────────────────────────────────────────────def performance_color(rate, max_rate, min_rate):"""Gradient from neutral (low) to primary (mid) to accent (high)""" normalized = (rate - min_rate) / (max_rate - min_rate +1e-10)def hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#')returntuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))if normalized <0.5:# Low to mid: neutral → primary blend = normalized *2 rgb_low = hex_to_rgb(TRAWAY_COLORS["neutral"]) rgb_high = hex_to_rgb(TRAWAY_COLORS["primary"])else:# Mid to high: primary → accent blend = (normalized -0.5) *2 rgb_low = hex_to_rgb(TRAWAY_COLORS["primary"]) rgb_high = hex_to_rgb(TRAWAY_COLORS["accent"]) r =int(rgb_low[0] * (1- blend) + rgb_high[0] * blend) g =int(rgb_low[1] * (1- blend) + rgb_high[1] * blend) b =int(rgb_low[2] * (1- blend) + rgb_high[2] * blend)returnf'rgb({r},{g},{b})'bar_colors = [ performance_color( row["repeat_rate"], year_loyalty["repeat_rate"].max(), year_loyalty["repeat_rate"].min() )for _, row in year_loyalty.iterrows()]# ─────────────────────────────────────────────# Create bar chart# ─────────────────────────────────────────────fig = go.Figure()fig.add_trace(go.Bar( x=year_loyalty["enrollment_year"].astype(str), y=year_loyalty["repeat_rate"], marker=dict( color=bar_colors, line=dict(color="white", width=1.5) ), text=year_loyalty["repeat_rate"].round(1), texttemplate='<b>%{text}%</b>', textposition="outside", textfont=dict(size=12), customdata=np.column_stack(( year_loyalty["total_students"], year_loyalty["repeat_customers"], year_loyalty["avg_trips"], year_loyalty["median_trips"] )), hovertemplate=("<b>Batch %{x}</b><br>""Repeat Rate: %{y:.1f}%<br>""Repeat Customers: %{customdata[1]:,}<br>""Avg Trips: %{customdata[2]:.2f}<br>""Median Trips: %{customdata[3]:.0f}""<extra></extra>" )))# ─────────────────────────────────────────────# Add overall average line# ─────────────────────────────────────────────fig.add_hline( y=overall_repeat, line_dash="dot", line_color=TRAWAY_COLORS["neutral"], line_width=2, opacity=0.6, annotation_text=f"Overall: {overall_repeat:.1f}%", annotation_position="right", annotation_font=dict(size=11, color=TRAWAY_COLORS["neutral"]))# ─────────────────────────────────────────────# Layout# ─────────────────────────────────────────────fig.update_layout( title=dict( text=f"Customer Loyalty by Enrollment Batch<br><sup>Batch {int(best_year['enrollment_year'])} leads at {best_year['repeat_rate']:.1f}% | Cohort analysis across graduating (M.tech, Phd included) classes</sup>", x=0.5, xanchor='center' ), xaxis=dict( title="Enrollment Year (Student Batch)", gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="Repeat Customer Rate (%)", gridcolor=TRAWAY_COLORS["light"],range=[0, max(year_loyalty["repeat_rate"]) *1.15] ), height=500, plot_bgcolor="white", margin=dict(t=90, b=70, l=70, r=40), showlegend=False)# ─────────────────────────────────────────────# Add insight annotation# ─────────────────────────────────────────────fig.add_annotation( x=0.02, y=1, xref="paper", yref="paper", text=(f"<b>Cohort Insights</b><br>"f"Strongest: {int(best_year['enrollment_year'])} ({best_year['repeat_rate']:.1f}%)<br>"f"Weakest: {int(worst_year['enrollment_year'])} ({worst_year['repeat_rate']:.1f}%)<br>" ), showarrow=False, font=dict(size=10, color=TRAWAY_COLORS["neutral"]), bgcolor="white", bordercolor=TRAWAY_COLORS["light"], borderwidth=2, borderpad=6, align="left", xanchor="left", yanchor="top")fig.show()
============================================================
LOYALTY BY ENROLLMENT YEAR
============================================================
Best performing batch: 2021 (58.0%)
Worst performing batch: 2019 (19.2%)
Overall repeat rate: 45.8%
============================================================
Figure 11: Customer loyalty by enrollment batch (graduating class)
(Figure 19: Customer loyalty by enrollment year — showing repeat customer rates across different student batches.)
No surprises here — the 2021 batch, the very group from which Traway was born, led the chart with an impressive 58% repeat rate. They were followed closely by the 2020 batch (54.6%), while earlier years like 2019 lagged behind. It made perfect sense: our batch trusted the brand because they were the brand — classmates, friends, and juniors who had seen Traway grow firsthand. Communication was smoother, the bond was stronger, and the story was personal.
Now, let’s investigate where or not we can see some patterns in loyality by branch.
============================================================
LOYALTY BY ACADEMIC BRANCH
============================================================
branch repeat_customers avg_trips median_trips repeat_rate
Mechanical Engineering 348 2.446768 2.0 66.159696
Electrical Engineering 302 1.864286 2.0 53.928571
Chemical Engineering 245 1.648956 1.0 46.489564
Civil Engineering 335 1.661581 1.0 46.463245
Information Technology 196 1.620536 1.0 43.750000
Metallurgical and Materials Engineering 185 1.422907 1.0 40.748899
Computer Science and Engineering 166 1.309051 1.0 36.644592
Electronics and Communication Engineering 146 1.084149 1.0 28.571429
Best performing branch: Mechanical Engineering (66.2%)
Worst performing branch: Electronics and Communication Engineering (28.6%)
Overall repeat rate: 45.8%
============================================================
Figure 12: Customer loyalty by academic discipline
(Figure 20: Customer loyalty by academic branch — analyzing repeat behavior across engineering disciplines.)
The second graph confirmed what we already suspected — Mechanical Engineering (what our college referred to as The Royal Mechanical) reigned supreme with a 66.2% repeat rate, followed by Electrical Engineering at 53.9%. This wasn’t a coincidence; most of our founding and operational team came from these very branches. Familiar faces, easy word-of-mouth, and shared class networks turned into a loyalty engine stronger than any marketing campaign.
Meanwhile, branches like Electronics and Communication and Computer Science showed lower retention, likely due to fewer direct connections. In other words, this chart didn’t just reflect customer behavior — it mapped the social architecture of Traway itself. Royal Mechanical, as always, led the way.
When It Mattered Most — The Emergency Evacuation Operation
Some data points feel too calm for what they represent.
This one — 2,166 students, 50 trips, 3 main locations — looks like just another operational metric. But behind those numbers was one of the most chaotic nights of our lives.
While it may now seem like a story told in calm hindsight, that night in December 2023 was anything but calm. Srinagar was burning — not literally, but socially, emotionally. A single “blasphemous” comment by a student had set off protests that spread like wildfire. What began as a tense college demonstration quickly grew into full-scale unrest across the city. Even after the student was arrested and an FIR was filed, the anger didn’t cool — it deepened. And right in the middle of it all were thousands of students from outside Kashmir, stranded in a campus that was suddenly no longer safe.
It was supposed to be exam season — libraries, notes, chai breaks. Instead, it was curfew, confusion, and rising panic. As the threats intensified, the government made a decision unprecedented in NIT Srinagar’s history: shut the college down immediately. No exams. No ceremonies. Just evacuation.
At around 2:00 a.m., the Director, professors, and DSP Srinagar gathered, scrambling for a plan. How do you move two thousand students out of a locked-down valley by dawn? No buses. No time. No backup. That’s when all the late nights, spreadsheets, and logistical tinkering behind Traway suddenly meant something.
We had the network, the funds, and the will to act. Within hours, routes were activated — 35 buses to Jammu, 8 to the airport, 7 local shuttles — each one filled, coordinated, and cleared under police supervision.
Show code
import plotly.graph_objects as gofrom plotly.subplots import make_subplotsimport numpy as npimport pandas as pd# ─────────────────────────────────────────────# Prepare emergency event data# ─────────────────────────────────────────────emergency_trips = trips[trips["trip_type"].str.lower() =="emergency"].copy()emergency_bookings = bookings[bookings["trip_id"].isin(emergency_trips["trip_id"])].copy()paid_emergency = emergency_bookings[emergency_bookings["payment_status"] =="paid"].copy()# Key metricstotal_students_evacuated = emergency_trips["total_seats"].sum()total_trips_deployed =len(emergency_trips)total_seats_deployed = emergency_trips["total_seats"].sum()students_booked =len(paid_emergency)occupancy_rate = students_booked / total_seats_deployed *100# Routes breakdownroutes_summary = emergency_trips.groupby("route").agg({"trip_id": "count","total_seats": "sum"}).reset_index()routes_summary.columns = ["route", "trips", "seats"]display(routes_summary)# Clean route names - remove everything before the arrow and the arrow itselfdef clean_route_name(route_str):"""Extract destination from route (e.g., 'Srinagar—Jammu' -> 'Jammu')"""# Try various dash/arrow charactersfor sep in ['—', '-', '–', '→']:if sep instr(route_str): parts =str(route_str).split(sep)return parts[-1].strip() # Return destination (last part)returnstr(route_str)routes_summary["route_clean"] = routes_summary["route"].apply(clean_route_name)# Calculate students per routeroute_bookings = paid_emergency.merge( emergency_trips[["trip_id", "route"]], on="trip_id", how="left")students_per_route = route_bookings.groupby("route")["student_id"].nunique().reset_index()students_per_route.columns = ["route", "students_evacuated"]routes_summary = routes_summary.merge(students_per_route, on="route", how="left")routes_summary["route_occupancy"] = routes_summary["students_evacuated"] / routes_summary["seats"] *100# Sort by students evacuated descendingroutes_summary = routes_summary.sort_values("students_evacuated", ascending=True)# ─────────────────────────────────────────────# Create horizontal bar chart (route impact)# ─────────────────────────────────────────────fig = go.Figure()# Gradient colors: deeper blue for higher impact routesdef route_color(students, max_students):"""Gradient from light to deep blue based on students evacuated""" ratio = students / max_studentsif ratio >0.6:return TRAWAY_COLORS["primary"] # Deep blueelif ratio >0.3:return TRAWAY_COLORS["accent"] # Goldelse:return TRAWAY_COLORS["neutral"] # Graycolors = [ route_color(row["students_evacuated"], routes_summary["students_evacuated"].max())for _, row in routes_summary.iterrows()]# Create text labels separately to avoid formatting issuestext_labels = []for _, row in routes_summary.iterrows(): label =f"<b>{int(row['seats'])}</b> students<br>{int(row['trips'])} trips" text_labels.append(label)fig.add_trace(go.Bar( y=routes_summary["route_clean"], x=routes_summary["students_evacuated"], orientation='h', marker=dict( color=colors, line=dict(color="white", width=1.5) ), text=text_labels, textposition="auto", textfont=dict(size=11), customdata=np.column_stack(( routes_summary["trips"], routes_summary["seats"], routes_summary["route_occupancy"] )), hovertemplate=("<b>%{y}</b><br>"# "Students evacuated: %{x:,.0f}<br>""Trips deployed: %{customdata[0]:.0f}<br>"# "Students Seats: %{customdata[1]:,.0f}<br>"# "Occupancy: %{customdata[2]:.1f}%""<extra></extra>" )))# ─────────────────────────────────────────────# Add key metrics annotation# ─────────────────────────────────────────────fig.add_annotation( x=0.98, y=0.58, xref="paper", yref="paper", text=(f"<b>Operation Summary</b><br>"f"Date: Dec 1, 2023<br>"f"Students: {total_seats_deployed:,}<br>"f"Trips: {total_trips_deployed}<br>"# f"Occupancy: {occupancy_rate:.0f}%" ), showarrow=False, font=dict(size=11, color=TRAWAY_COLORS["primary"]), bgcolor="white", bordercolor=TRAWAY_COLORS["primary"], borderwidth=2, borderpad=8, align="left", xanchor="right", yanchor="top")# ─────────────────────────────────────────────# Layout# ─────────────────────────────────────────────fig.update_layout( title=dict( text=f"Emergency Evacuation Operation<br><sup>December 2023 | {total_seats_deployed:,} students evacuated in {total_trips_deployed} trips | When the valley shut down, the buses didn't</sup>", x=0.5, xanchor='center' ), xaxis=dict( title="Students Evacuated", gridcolor=TRAWAY_COLORS["light"] ), yaxis=dict( title="", gridcolor=TRAWAY_COLORS["light"] ), height=400, plot_bgcolor="white", margin=dict(t=100, b=70, l=140, r=180), showlegend=False)fig.show()
route
trips
seats
0
Srinagar–Airport
8
330
1
Srinagar–Jammu
35
1512
2
Srinagar–Local
7
324
(a) Emergency evacuation operation - December 2023
(b)
Figure 13
(Figure 21: December 2023 emergency evacuation — 2,166 students safely evacuated overnight across 50 trips.)
Looking back, those numbers — 50 trips, 2,166 students — look neat on paper. But they carry a weight that doesn’t fit in any cell of Excel. Because that night wasn’t about operations. It was about responsibility. When the valley shut down, we didn’t. What started as a student project became, for one terrifying night, a lifeline.
And maybe that’s when we truly understood what we had built.
What does all of it mean?
When we started Traway, we weren’t trying to build a company. We were just a handful of students trying to solve an inconvenient problem — how to get home safely, comfortably, and on time. What followed was years of learning, missteps, late nights, and spreadsheets that somehow turned into buses, systems, and trust.
We learned to read behavior before revenue, to fix systems before margins, and to value people before plans. We discovered that leadership isn’t loud — it’s often 2 a.m. phone calls, quiet decisions, and a group of friends refusing to let things fall apart.
It’s easy now to look back and see graphs, charts, and clean data visualizations. But those lines and colors were once chaos — unpredictable, emotional, deeply human. The magic wasn’t in what we built, but when and how we built it: while juggling classes, deadlines, and the uncertainty of being twenty-somethings trying to make sense of the world.
And yet, that’s exactly what made it powerful. Because Traway was never just about movement, it was about motion. It was proof that even small, imperfect ideas can carry real weight when driven by empathy and execution.
The courage Traway gave us led to something new in our third year of college — something we had dreamed about since day one: filling the enormous gap we saw in the Kashmir’s foodscape. That spark became The Loud Kitchens.
Though it ran for only a brief time, it was built on the same foundation of discipline, resourcefulness, and capital flow that Traway made possible. It became our second experiment in creating strartups that serve people - just in a different domain. An analytical deep-dive into that journey — from mobility to meals — will be Coming Soon
For now, though, we bid you adieu. This isn’t really a goodbye — it’s more of a baton pass.
To every future builder who reads this: you don’t need permission to start, or certainty to lead. You just need a reason, a few good people, and the courage to act when the world goes still.