from otree.api import * import numpy as np import random import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import io import base64 from collections import Counter doc = """ Welcome to the newsvendor simulation game. """ class C(BaseConstants): NAME_IN_URL = 'newsvendor' PLAYERS_PER_GROUP = None NUM_ROUNDS = 5 HISTORY_SAMPLES = 5 DEMAND_VALUES = [50, 100, 150, 200, 250, 300, 350, 400, 450, 500] DEMAND_WEIGHTS = [1, 2, 4, 6, 8, 8, 6, 4, 2, 1] SCENARIOS = [ dict(p=100.0, s=20.0, ratio=0.30), dict(p=120.0, s=30.0, ratio=0.50), dict(p=90.0, s=10.0, ratio=0.70), dict(p=70.0, s=20.0, ratio=0.80), dict(p=110.0, s=40.0, ratio=0.60), dict(p=95.0, s=15.0, ratio=0.45), ] class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): mean_demand = models.FloatField() std_demand = models.FloatField() order_quantity = models.FloatField(min=0) actual_demand = models.FloatField() profit = models.FloatField() sales = models.FloatField(initial=0) leftover = models.FloatField(initial=0) loss = models.FloatField(initial=0) max_possible_profit = models.FloatField() score = models.FloatField() price = models.FloatField() cost = models.FloatField() salvage = models.FloatField() is_smart_group = models.BooleanField(initial=False) def get_discrete_stats(values, weights): """Helper to calculate mean and std for a discrete distribution.""" total_weight = sum(weights) if total_weight == 0: return 0, 0 probabilities = [w / total_weight for w in weights] mean = sum(v * p for v, p in zip(values, probabilities)) variance = sum(((v - mean) ** 2) * p for v, p in zip(values, probabilities)) std = variance ** 0.5 return mean, std def creating_session(subsession: Subsession): """Assign each participant a fixed scenario and initial history.""" if subsession.round_number == 1: for player in subsession.get_players(): pv = player.participant.vars scenario = random.choice(C.SCENARIOS) p = float(scenario['p']) s = float(scenario['s']) r = float(scenario['ratio']) c = float(round(p - r * (p - s))) demand_values = C.DEMAND_VALUES.copy() demand_weights = C.DEMAND_WEIGHTS.copy() mean_demand, std_demand = get_discrete_stats(demand_values, demand_weights) hist = random.choices(demand_values, weights=demand_weights, k=C.HISTORY_SAMPLES) pv['fixed_scenario'] = dict( p=p, s=s, r=r, c=c, history=hist, demand_values=demand_values, demand_weights=demand_weights, mean=mean_demand, std=std_demand ) pv['actual_demand_history'] = [] pv['profit_history'] = [] pv['maxprofit_history'] = [] pv['order_history'] = [] pv['sales_history'] = [] pv['leftover_history'] = [] pv['loss_history'] = [] # For detailed feedback for player in subsession.get_players(): pv = player.participant.vars fixed = pv['fixed_scenario'] player.mean_demand = float(fixed['mean']) player.std_demand = float(fixed['std']) player.price = float(fixed['p']) player.cost = float(fixed['c']) player.salvage = float(fixed['s']) player.actual_demand = random.choices(fixed['demand_values'], weights=fixed['demand_weights'], k=1)[0] player.max_possible_profit = calculate_profit( player.actual_demand, player.actual_demand, player.price, player.cost, player.salvage ) is_smart = pv.get('is_smart_group', False) pv['treatment'] = 'experienced' if is_smart else 'history_support' def calculate_optimal_quantity(demand_values, demand_weights, p, c, s): """Calculates optimal order quantity for a DISCRETE distribution.""" if (p - s) <= 0: return float(min(demand_values)) critical_fractile = (p - c) / (p - s) total_weight = sum(demand_weights) if total_weight == 0: return float(min(demand_values)) probabilities = [w / total_weight for w in demand_weights] dist = sorted(zip(demand_values, probabilities)) cumulative_prob = 0 for value, prob in dist: cumulative_prob += prob if cumulative_prob >= critical_fractile: return float(value) return float(dist[-1][0]) def calculate_profit(quantity, demand, p, c, s): return min(quantity, demand) * p + max(0, quantity - demand) * s - quantity * c class Order(Page): form_model = 'player' form_fields = ['order_quantity'] @staticmethod def js_vars(player: Player): return dict( selling_price=player.price, cost_price=player.cost, salvage_value=player.salvage, ) @staticmethod def vars_for_template(player: Player): pv = player.participant.vars fixed_scenario = pv.get('fixed_scenario', {}) tool_count = int(pv.get('tool_count', 1)) show_demand_forecast = tool_count >= 2 show_dashboard = tool_count >= 4 and player.round_number > 1 day_number = player.round_number + C.HISTORY_SAMPLES synthetic_hist = fixed_scenario.get('history', []) actual_hist = pv.get('actual_demand_history', []) historical_demand_table = [{'interval': f"Day {i+1}", 'demand': int(d)} for i, d in enumerate(synthetic_hist)] for i, d in enumerate(actual_hist): historical_demand_table.append({'interval': f"Round {i+1} (Day {i+1+C.HISTORY_SAMPLES})", 'demand': int(d)}) history_stats = None if show_demand_forecast: data_for_stats = synthetic_hist + actual_hist if data_for_stats: history_stats = dict( mean=round(float(np.mean(data_for_stats)), 2), std=round(float(np.std(data_for_stats, ddof=1)) if len(data_for_stats) > 1 else 0, 2), min=int(np.min(data_for_stats)), max=int(np.max(data_for_stats)), n=len(data_for_stats) ) dist_data_uri = '' if show_demand_forecast: combined_hist = synthetic_hist + actual_hist if combined_hist: counts = Counter(combined_hist) total_obs = len(combined_hist) observed_values = sorted(counts.keys()) probabilities = [counts[val] / total_obs for val in observed_values] fig, ax = plt.subplots(figsize=(6, 3)) ax.bar(observed_values, probabilities, width=20, color='purple', edgecolor='black', label='Observed Frequency') ax.set_title('Observed Demand Distribution') ax.set_xlabel('Demand') ax.set_ylabel('Probability (Frequency)') ax.set_xticks(C.DEMAND_VALUES) ax.set_xlim(min(C.DEMAND_VALUES) - 25, max(C.DEMAND_VALUES) + 25) ax.tick_params(axis='x', rotation=45) ax.legend() fig.tight_layout() buf = io.BytesIO() fig.savefig(buf, format='png') plt.close(fig) buf.seek(0) dist_b64 = base64.b64encode(buf.read()).decode('ascii') buf.close() dist_data_uri = f"data:image/png;base64,{dist_b64}" # --- START: New PDF/CDF Table Logic --- demand_stats_table = { 'headers': [], 'pdf': [], 'cdf': [] } combined_hist = synthetic_hist + actual_hist total_demand_count = len(combined_hist) if total_demand_count > 0: counts = Counter(combined_hist) # Get all canonical demand values (e.g., [50, 100, ... 500]) canonical_values = sorted(fixed_scenario.get('demand_values', C.DEMAND_VALUES)) # Find the maximum demand observed so far max_observed_demand = max(combined_hist) # Filter the canonical values to only show up to the max observed all_possible_values = [val for val in canonical_values if val <= max_observed_demand] cumulative_prob = 0.0 for val in all_possible_values: count = counts.get(val, 0) # Get count for this value, default to 0 pdf_val = count / total_demand_count cumulative_prob += pdf_val demand_stats_table['headers'].append(int(val)) # Add the value as a header demand_stats_table['pdf'].append(f"{pdf_val:.2f}") demand_stats_table['cdf'].append(f"{cumulative_prob:.2f}") # --- END: New PDF/CDF Table Logic --- # --- LOGIC FOR DASHBOARD AND FEEDBACK --- dash_orders_demand = '' dash_profit = '' dash_sales_leftovers = '' if show_dashboard: orders_hist = pv.get('order_history', []) profits_hist = pv.get('profit_history', []) sales_hist = pv.get('sales_history', []) leftovers_hist = pv.get('leftover_history', []) actual_hist = pv.get('actual_demand_history', []) # Ensure this is defined here n_prev = player.round_number - 1 if n_prev > 0: rounds_prev = list(range(1, n_prev + 1)) # --- Plot 1: Orders vs Demand --- fig_od, ax_od = plt.subplots(figsize=(6, 3)) ax_od.plot(rounds_prev, orders_hist[:n_prev], marker='o', linestyle='-', color='red', label='Your Order') ax_od.plot(rounds_prev, actual_hist[:n_prev], marker='o', linestyle='--', color='blue', label='Actual Demand') ax_od.set_xticks(rounds_prev) ax_od.set_title('Orders vs Demand') ax_od.legend() buf_od = io.BytesIO() fig_od.savefig(buf_od, format='png') plt.close(fig_od) buf_od.seek(0) # <-- FIX: Rewind buffer dash_orders_demand = f"data:image/png;base64,{base64.b64encode(buf_od.read()).decode('ascii')}" buf_od.close() # <-- Good practice # --- Plot 2: Profit by Round --- fig_p, ax_p = plt.subplots(figsize=(6, 3)) ax_p.plot(rounds_prev, profits_hist[:n_prev], marker='o', linestyle='-', color='green', label='Profit') ax_p.set_xticks(rounds_prev) ax_p.set_title('Profit by Round') ax_p.set_ylabel('Profit ($)') ax_p.legend() buf_p = io.BytesIO() fig_p.savefig(buf_p, format='png') plt.close(fig_p) buf_p.seek(0) # <-- FIX: Rewind buffer dash_profit = f"data:image/png;base64,{base64.b64encode(buf_p.read()).decode('ascii')}" buf_p.close() # <-- Good practice # --- Plot 3: Sales vs Leftovers --- fig_sl, ax_sl = plt.subplots(figsize=(6, 3)) ax_sl.plot(rounds_prev, sales_hist[:n_prev], marker='o', linestyle='-', color='blue', label='Units Sold') ax_sl.plot(rounds_prev, leftovers_hist[:n_prev], marker='o', linestyle='--', color='orange', label='Leftover Units') ax_sl.set_xticks(rounds_prev) ax_sl.set_title('Sales vs Leftovers') ax_sl.set_ylabel('Units') ax_sl.legend() buf_sl = io.BytesIO() fig_sl.savefig(buf_sl, format='png') plt.close(fig_sl) buf_sl.seek(0) # <-- FIX: Rewind buffer dash_sales_leftovers = f"data:image/png;base64,{base64.b64encode(buf_sl.read()).decode('ascii')}" buf_sl.close() # <-- Good practice # --- START: NEW DETAILED FEEDBACK LOGIC --- p = player.price c = player.cost s = player.salvage cu = p - c # Cost of underage co = c - s # Cost of overage if (p - s) <= 0: critical_fractile = 0.0 # Avoid division by zero, edge case else: critical_fractile = cu / (cu + co) # Calculate theoretical Q* based on the game's true distribution theoretical_q = calculate_optimal_quantity( fixed_scenario['demand_values'], fixed_scenario['demand_weights'], p, c, s ) last_feedback = None if player.round_number > 1: last_order = pv['order_history'][-1] last_actual = pv['actual_demand_history'][-1] last_leftover = pv['leftover_history'][-1] last_missed = pv['loss_history'][-1] last_profit = pv['profit_history'][-1] last_max_profit = pv['maxprofit_history'][-1] last_score = (last_profit / last_max_profit) * 100 if last_max_profit > 0 else 0 # 1. Generate feedback reason (Why this score?) feedback_reason = "" if last_leftover > 0: feedback_reason = f"Your score was impacted because you overshot the demand. You had {last_leftover:.0f} unsold units, which cost you ${last_leftover * co:.2f} in overage costs (Cost - Salvage)." elif last_missed > 0: feedback_reason = f"Your score was impacted because you undershot the demand. You missed out on {last_missed:.0f} potential sales, which cost you ${last_missed * cu:.2f} in lost profit (Price - Cost)." else: feedback_reason = "Great job! Your order matched demand perfectly, maximizing your potential profit for that day." # 2. Generate feedback advice (How to improve?) feedback_advice = "" if last_order > theoretical_q: feedback_advice = f"Your order of {last_order:.0f} was above the theoretical optimal quantity of {theoretical_q:.0f}." elif last_order < theoretical_q: feedback_advice = f"Your order of {last_order:.0f} was below the theoretical optimal quantity of {theoretical_q:.0f}." else: feedback_advice = f"Your order of {last_order:.0f} matched the theoretical optimal quantity perfectly!" last_feedback = dict( round=player.round_number - 1, profit=round(last_profit, 2), max_profit=round(last_max_profit, 2), order=round(last_order, 2), actual=int(last_actual), score=round(last_score, 2), critical_fractile=round(critical_fractile, 3), theoretical_q=round(theoretical_q, 2), feedback_reason=feedback_reason, feedback_advice=feedback_advice, critical_fractile_percent=round(critical_fractile * 100, 1) ) # --- END: NEW DETAILED FEEDBACK LOGIC --- # Calculate optimal quantity based on OBSERVED distribution (for recommendation tool) combined_hist = synthetic_hist + actual_hist if combined_hist: counts = Counter(combined_hist) empirical_values = sorted(counts.keys()) empirical_weights = [counts[val] for val in empirical_values] recommended_q = calculate_optimal_quantity( empirical_values, empirical_weights, player.price, player.cost, player.salvage ) else: # Fallback to theoretical if no history (shouldn't happen after round 1) recommended_q = theoretical_q backstory = ( "You're running a pop-up lunch stall in a busy market. Some days the crowds are huge, " "other days it's quieter. You decide how many boxes to prepare before the lunch rush. " "Whatever doesn't sell can be salvaged for a smaller amount, and whatever you don't have " "in stock becomes a missed opportunity." ) return dict( round_number=player.round_number, day_number=day_number, p=player.price, c=player.cost, s=player.salvage, backstory=backstory, tool_count=tool_count, historical_demand_table=historical_demand_table, show_demand_forecast=show_demand_forecast, history_stats=history_stats, plot_distribution=dist_data_uri, demand_stats_table=demand_stats_table, show_performance=tool_count >= 3, show_dashboard=show_dashboard, dash_orders_demand=dash_orders_demand, dash_profit=dash_profit, dash_sales_leftovers=dash_sales_leftovers, show_recommendation=tool_count >= 4, recommended_order=round(recommended_q, 2), show_feedback=tool_count >= 2, last_feedback=last_feedback, score_definition="Score = (Your Profit ÷ Max Possible Profit) × 100", ) @staticmethod def before_next_page(player: Player, timeout_happened): player.sales = float(min(player.order_quantity, player.actual_demand)) player.leftover = float(max(0, player.order_quantity - player.actual_demand)) player.loss = float(max(0, player.actual_demand - player.order_quantity)) player.profit = calculate_profit( player.order_quantity, player.actual_demand, player.price, player.cost, player.salvage ) player.score = (player.profit / player.max_possible_profit) * 100 if player.max_possible_profit > 0 else 0 class Results(Page): @staticmethod def vars_for_template(player: Player): pv = player.participant.vars tool_count = int(pv.get('tool_count', 1)) show_tool_prompt = tool_count >= 2 return dict( round_number=player.round_number, actual_demand=int(player.actual_demand), order_quantity=round(player.order_quantity, 2), profit=round(player.profit, 2), max_possible_profit=round(player.max_possible_profit, 2), score=round(player.score, 2), difference=round(player.max_possible_profit - player.profit, 2), leftover=round(player.leftover, 2), loss=round(player.loss, 2), sales=round(player.sales, 2), show_tool_prompt=show_tool_prompt # Pass the new variable ) @staticmethod def before_next_page(player: Player, timeout_happened): pv = player.participant.vars pv['actual_demand_history'].append(player.actual_demand) pv['profit_history'].append(player.profit) pv['maxprofit_history'].append(player.max_possible_profit) pv['order_history'].append(player.order_quantity) pv['sales_history'].append(player.sales) pv['leftover_history'].append(player.leftover) pv['loss_history'].append(player.loss) # For detailed feedback class FinalResults(Page): @staticmethod def is_displayed(player: Player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player: Player): total_profit = sum(p.profit for p in player.in_all_rounds()) total_max_profit = sum(p.max_possible_profit for p in player.in_all_rounds()) total_score = sum(p.score for p in player.in_all_rounds()) labels = ['Your Total Profit', 'Max Possible Profit'] values = [total_profit, total_max_profit] fig, ax = plt.subplots(figsize=(5, 4)) bars = ax.bar(labels, values, color=['green', 'gray']) ax.set_ylabel("Profit") ax.set_title("Final Results") ax.bar_label(bars, fmt='$%.2f') buf = io.BytesIO() plt.tight_layout() plt.savefig(buf, format='png') plt.close() buf.seek(0) # <-- Also added here for consistency, though it was missing image_b64 = base64.b64encode(buf.read()).decode('ascii') buf.close() # <-- Also added here plot_url = f"data:image/png;base64,{image_b64}" return dict( total_profit=round(total_profit, 2), total_max_profit=round(total_max_profit, 2), average_profit=round(total_profit / C.NUM_ROUNDS, 2), total_score=round(total_score, 2), average_score=round(total_score / C.NUM_ROUNDS, 2), difference=round(total_max_profit - total_profit, 2), plot_url=plot_url, ) page_sequence = [Order, Results, FinalResults]