From b34b8ff6377f0ff3616d9c2ac673d7348ebd81dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 09:30:20 +0000 Subject: [PATCH 1/8] Fix accuracy metrics and remove overall score - Fixed accuracy calculation to show % of predictions within 20% of actual (proper metric) - Renamed overall_error_pct to mape for clarity - Removed overall_score calculation (was combining accuracy, profit, and service level) - Updated results template to show MAPE and prediction accuracy side-by-side - Added explanatory text for both metrics (lower/higher is better) --- forecast_app/app.py | 39 +++++++++++++---------------- forecast_app/templates/results.html | 31 ++++++++++++++--------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index 4b2ba7e..cbbe2db 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -181,33 +181,30 @@ def calculate_results(predictions, test_data): # Calculate overall prediction accuracy all_errors = [] + accurate_predictions = 0 # Count predictions within 20% of actual + total_predictions = 0 + for watch_id, perf in results['watch_performance'].items(): + # MAPE for this watch avg_error = sum(perf['errors']) / len(perf['errors']) - perf['avg_error_pct'] = round(avg_error, 1) - perf['accuracy'] = round(100 - avg_error, 1) - all_errors.extend(perf['errors']) - - results['prediction_accuracy'] = { - 'overall_error_pct': round(sum(all_errors) / len(all_errors), 1), - 'overall_accuracy': round(100 - (sum(all_errors) / len(all_errors)), 1) - } + perf['mape'] = round(avg_error, 1) - # Calculate score (0-100) - # Based on: accuracy (50%), profit (30%), service level (20%) - accuracy_score = results['prediction_accuracy']['overall_accuracy'] * 0.5 + # Prediction accuracy: % of predictions within 20% of actual + accurate_count = sum(1 for err in perf['errors'] if err <= 20) + perf['accuracy'] = round((accurate_count / len(perf['errors']) * 100), 1) - # Profit score (normalize to potential max profit) - potential_profit = results['financial_summary']['total_revenue'] # If perfect predictions - actual_profit = results['financial_summary']['total_profit'] - profit_score = min(30, (actual_profit / potential_profit * 30)) if potential_profit > 0 else 0 + all_errors.extend(perf['errors']) + accurate_predictions += accurate_count + total_predictions += len(perf['errors']) - # Service level score (based on stockouts) - total_actual = sum(perf['total_actual'] for perf in results['watch_performance'].values()) - total_sold = sum(perf['total_sold'] for perf in results['watch_performance'].values()) - service_level = (total_sold / total_actual * 100) if total_actual > 0 else 0 - service_score = service_level * 0.2 + # Overall metrics + overall_mape = sum(all_errors) / len(all_errors) + overall_accuracy = (accurate_predictions / total_predictions * 100) if total_predictions > 0 else 0 - results['overall_score'] = round(accuracy_score + profit_score + service_score, 1) + results['prediction_accuracy'] = { + 'mape': round(overall_mape, 1), + 'accuracy': round(overall_accuracy, 1) + } return results diff --git a/forecast_app/templates/results.html b/forecast_app/templates/results.html index 70d636c..f90faf5 100644 --- a/forecast_app/templates/results.html +++ b/forecast_app/templates/results.html @@ -14,15 +14,22 @@
- +
-
+
-

Overall Performance Score

-
{{ results.overall_score }}/100
-

- Prediction Accuracy: {{ results.prediction_accuracy.overall_accuracy }}% -

+

MAPE

+

Mean Absolute Percentage Error

+
{{ results.prediction_accuracy.mape }}%
+

Lower is better

+
+
+
+
+

Prediction Accuracy

+

% within 20% of actual

+
{{ results.prediction_accuracy.accuracy }}%
+

Higher is better

@@ -195,19 +202,19 @@

Key Insights

- {% if results.prediction_accuracy.overall_accuracy >= 90 %} + {% if results.prediction_accuracy.accuracy >= 90 %}
-

Excellent work! Your predictions were highly accurate ({{ results.prediction_accuracy.overall_accuracy }}%).

+

Excellent work! Your predictions were highly accurate ({{ results.prediction_accuracy.accuracy }}% within 20% of actual, MAPE: {{ results.prediction_accuracy.mape }}%).

You demonstrated strong understanding of demand patterns and seasonality.

- {% elif results.prediction_accuracy.overall_accuracy >= 75 %} + {% elif results.prediction_accuracy.accuracy >= 75 %}
-

Good job! Your predictions were reasonably accurate ({{ results.prediction_accuracy.overall_accuracy }}%).

+

Good job! Your predictions were reasonably accurate ({{ results.prediction_accuracy.accuracy }}% within 20% of actual, MAPE: {{ results.prediction_accuracy.mape }}%).

There's room for improvement - review the monthly patterns to identify where you can refine your forecasts.

{% else %}
-

Keep learning! Your predictions had significant variance from actual demand ({{ results.prediction_accuracy.overall_accuracy }}% accuracy).

+

Keep learning! Your predictions had significant variance from actual demand ({{ results.prediction_accuracy.accuracy }}% within 20% of actual, MAPE: {{ results.prediction_accuracy.mape }}%).

Consider: Did you account for seasonal patterns? Did you follow the growth trends?

{% endif %} -- GitLab From 4ffedd7cfa8822df8d3ff1922286bab956186c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 10:12:38 +0000 Subject: [PATCH 2/8] Implement proper MAPE and accuracy calculation methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created calculate_mape() function using proper MAPE formula: MAPE = (1/n) * Σ(|actual - predicted| / |actual|) * 100 - Created calculate_accuracy() function using total error ratio: Accuracy = (1 - Σ|actual - predicted| / Σactual) * 100 - Updated calculate_results() to store raw actual and predicted values - Use proper calculation methods instead of shortcuts - Updated template to accurately describe the metrics --- forecast_app/app.py | 89 +++++++++++++++++++++++------ forecast_app/templates/results.html | 8 +-- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index cbbe2db..9c4f93d 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -39,6 +39,60 @@ def load_test_data(): return json.load(f) +def calculate_mape(actual_values, predicted_values): + """ + Calculate Mean Absolute Percentage Error (MAPE) + + MAPE = (1/n) * Σ(|actual - predicted| / |actual|) * 100 + + Args: + actual_values: List of actual values + predicted_values: List of predicted values + + Returns: + MAPE as percentage + """ + if len(actual_values) == 0 or len(predicted_values) == 0: + return 0.0 + + n = len(actual_values) + total_percentage_error = 0.0 + + for actual, predicted in zip(actual_values, predicted_values): + if actual != 0: + total_percentage_error += abs(actual - predicted) / abs(actual) + + mape = (total_percentage_error / n) * 100 + return mape + + +def calculate_accuracy(actual_values, predicted_values): + """ + Calculate prediction accuracy as the complement of total error ratio + + Accuracy = (1 - Σ|actual - predicted| / Σactual) * 100 + + Args: + actual_values: List of actual values + predicted_values: List of predicted values + + Returns: + Accuracy as percentage + """ + if len(actual_values) == 0 or len(predicted_values) == 0: + return 0.0 + + total_actual = sum(actual_values) + if total_actual == 0: + return 0.0 + + total_absolute_error = sum(abs(actual - predicted) + for actual, predicted in zip(actual_values, predicted_values)) + + accuracy = (1 - (total_absolute_error / total_actual)) * 100 + return accuracy + + def calculate_results(predictions, test_data): """ Calculate financial results comparing predictions to actual data @@ -161,7 +215,8 @@ def calculate_results(predictions, test_data): 'total_stockout': 0, 'total_revenue': 0, 'total_profit': 0, - 'errors': [] + 'actual_values': [], + 'predicted_values': [] } perf = results['watch_performance'][watch_id] @@ -171,7 +226,8 @@ def calculate_results(predictions, test_data): perf['total_stockout'] += stockout perf['total_revenue'] += revenue perf['total_profit'] += profit - perf['errors'].append(error_pct) + perf['actual_values'].append(actual_demand) + perf['predicted_values'].append(predicted_demand) results['monthly_comparison'].append(month_comparison) @@ -179,27 +235,24 @@ def calculate_results(predictions, test_data): for key in results['financial_summary']: results['financial_summary'][key] = round(results['financial_summary'][key], 2) - # Calculate overall prediction accuracy - all_errors = [] - accurate_predictions = 0 # Count predictions within 20% of actual - total_predictions = 0 + # Calculate prediction metrics using proper formulas + all_actual_values = [] + all_predicted_values = [] for watch_id, perf in results['watch_performance'].items(): - # MAPE for this watch - avg_error = sum(perf['errors']) / len(perf['errors']) - perf['mape'] = round(avg_error, 1) + # Calculate MAPE for this watch using the proper formula + perf['mape'] = round(calculate_mape(perf['actual_values'], perf['predicted_values']), 1) - # Prediction accuracy: % of predictions within 20% of actual - accurate_count = sum(1 for err in perf['errors'] if err <= 20) - perf['accuracy'] = round((accurate_count / len(perf['errors']) * 100), 1) + # Calculate accuracy for this watch using the proper formula + perf['accuracy'] = round(calculate_accuracy(perf['actual_values'], perf['predicted_values']), 1) - all_errors.extend(perf['errors']) - accurate_predictions += accurate_count - total_predictions += len(perf['errors']) + # Accumulate for overall metrics + all_actual_values.extend(perf['actual_values']) + all_predicted_values.extend(perf['predicted_values']) - # Overall metrics - overall_mape = sum(all_errors) / len(all_errors) - overall_accuracy = (accurate_predictions / total_predictions * 100) if total_predictions > 0 else 0 + # Calculate overall metrics using proper formulas + overall_mape = calculate_mape(all_actual_values, all_predicted_values) + overall_accuracy = calculate_accuracy(all_actual_values, all_predicted_values) results['prediction_accuracy'] = { 'mape': round(overall_mape, 1), diff --git a/forecast_app/templates/results.html b/forecast_app/templates/results.html index f90faf5..e668234 100644 --- a/forecast_app/templates/results.html +++ b/forecast_app/templates/results.html @@ -27,7 +27,7 @@

Prediction Accuracy

-

% within 20% of actual

+

1 - (Total Error / Total Actual)

{{ results.prediction_accuracy.accuracy }}%

Higher is better

@@ -204,17 +204,17 @@
{% if results.prediction_accuracy.accuracy >= 90 %}
-

Excellent work! Your predictions were highly accurate ({{ results.prediction_accuracy.accuracy }}% within 20% of actual, MAPE: {{ results.prediction_accuracy.mape }}%).

+

Excellent work! Your predictions were highly accurate ({{ results.prediction_accuracy.accuracy }}% accuracy, {{ results.prediction_accuracy.mape }}% MAPE).

You demonstrated strong understanding of demand patterns and seasonality.

{% elif results.prediction_accuracy.accuracy >= 75 %}
-

Good job! Your predictions were reasonably accurate ({{ results.prediction_accuracy.accuracy }}% within 20% of actual, MAPE: {{ results.prediction_accuracy.mape }}%).

+

Good job! Your predictions were reasonably accurate ({{ results.prediction_accuracy.accuracy }}% accuracy, {{ results.prediction_accuracy.mape }}% MAPE).

There's room for improvement - review the monthly patterns to identify where you can refine your forecasts.

{% else %}
-

Keep learning! Your predictions had significant variance from actual demand ({{ results.prediction_accuracy.accuracy }}% within 20% of actual, MAPE: {{ results.prediction_accuracy.mape }}%).

+

Keep learning! Your predictions had significant variance from actual demand ({{ results.prediction_accuracy.accuracy }}% accuracy, {{ results.prediction_accuracy.mape }}% MAPE).

Consider: Did you account for seasonal patterns? Did you follow the growth trends?

{% endif %} -- GitLab From 0f09420dfb8597d12ba86ac80975ea00daed1d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 10:19:06 +0000 Subject: [PATCH 3/8] Fix MAPE calculation to use correct denominator - Changed calculate_mape to divide by valid_count (non-zero actuals) instead of total count - Added proper validation checks for empty/mismatched arrays - Made accuracy calculation more explicit with clear loop - Fixed bug where MAPE was incorrectly calculated when some actual values were zero --- forecast_app/app.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index 9c4f93d..24492b7 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -44,6 +44,7 @@ def calculate_mape(actual_values, predicted_values): Calculate Mean Absolute Percentage Error (MAPE) MAPE = (1/n) * Σ(|actual - predicted| / |actual|) * 100 + where n is the count of non-zero actual values Args: actual_values: List of actual values @@ -52,17 +53,25 @@ def calculate_mape(actual_values, predicted_values): Returns: MAPE as percentage """ - if len(actual_values) == 0 or len(predicted_values) == 0: + if not actual_values or not predicted_values: + return 0.0 + + if len(actual_values) != len(predicted_values): return 0.0 - n = len(actual_values) total_percentage_error = 0.0 + valid_count = 0 for actual, predicted in zip(actual_values, predicted_values): - if actual != 0: - total_percentage_error += abs(actual - predicted) / abs(actual) + if actual != 0: # Only include non-zero actuals + percentage_error = abs(actual - predicted) / abs(actual) + total_percentage_error += percentage_error + valid_count += 1 + + if valid_count == 0: + return 0.0 - mape = (total_percentage_error / n) * 100 + mape = (total_percentage_error / valid_count) * 100 return mape @@ -79,15 +88,19 @@ def calculate_accuracy(actual_values, predicted_values): Returns: Accuracy as percentage """ - if len(actual_values) == 0 or len(predicted_values) == 0: + if not actual_values or not predicted_values: + return 0.0 + + if len(actual_values) != len(predicted_values): return 0.0 total_actual = sum(actual_values) if total_actual == 0: return 0.0 - total_absolute_error = sum(abs(actual - predicted) - for actual, predicted in zip(actual_values, predicted_values)) + total_absolute_error = 0.0 + for actual, predicted in zip(actual_values, predicted_values): + total_absolute_error += abs(actual - predicted) accuracy = (1 - (total_absolute_error / total_actual)) * 100 return accuracy -- GitLab From 3cb575fc5138fd6ed4b8be91dea93049ce8642d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 10:24:56 +0000 Subject: [PATCH 4/8] Refactor calculate_results to use pandas DataFrames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete rewrite of data processing pipeline using pandas - Single source of truth: main DataFrame with all predictions and actuals - Vectorized calculations for all financial metrics - Clean separation: data collection → transformation → aggregation - Eliminated nested loops and manual accumulation - Much more maintainable and easier to debug - Includes DataFrame in results for future enhancements --- forecast_app/app.py | 284 +++++++++++++++++++++++--------------------- 1 file changed, 146 insertions(+), 138 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index 24492b7..e6727dd 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -10,6 +10,9 @@ import json import os from datetime import datetime from io import BytesIO +import pandas as pd +import numpy as np + try: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment @@ -108,7 +111,7 @@ def calculate_accuracy(actual_values, predicted_values): def calculate_results(predictions, test_data): """ - Calculate financial results comparing predictions to actual data + Calculate financial results comparing predictions to actual data using pandas Args: predictions: Dict of {watch_id: [12 monthly predictions]} @@ -117,162 +120,167 @@ def calculate_results(predictions, test_data): Returns: Dictionary with detailed results """ - watches = load_training_data()['metadata']['watches'] - - results = { - 'monthly_comparison': [], - 'watch_performance': {}, - 'financial_summary': { - 'total_revenue': 0, - 'total_costs': 0, - 'total_profit': 0, - 'lost_revenue': 0, - 'excess_costs': 0 - }, - 'prediction_accuracy': {} - } + watches_meta = load_training_data()['metadata']['watches'] + watch_lookup = {w['id']: w for w in watches_meta} - # Process each month + # Build the main DataFrame with all prediction and actual data + rows = [] for month_idx, month_data in enumerate(test_data): - month_comparison = { - 'month': month_idx + 1, - 'date': month_data['date'], - 'watches': [] - } - - # Process each watch for watch_data in month_data['watches']: watch_id = watch_data['watch_id'] - watch = next(w for w in watches if w['id'] == watch_id) - - # Get student's prediction predicted_demand = predictions.get(str(watch_id), [0] * 12)[month_idx] - actual_demand = watch_data['demand'] - # Calculate production based on prediction - # Student's prediction drives production - production = int(predicted_demand) + rows.append({ + 'month': month_idx + 1, + 'date': month_data['date'], + 'watch_id': watch_id, + 'watch_name': watch_lookup[watch_id]['name'], + 'predicted_demand': predicted_demand, + 'actual_demand': watch_data['demand'], + 'sell_price': watch_lookup[watch_id]['sell_price'], + 'base_cost': watch_lookup[watch_id]['base_cost'] + }) + + df = pd.DataFrame(rows) - # Calculate inventory (simple model: start with previous month's end) - if month_idx == 0: - inventory_start = 100 # Starting inventory for year 11 - else: - prev_watch = [w for w in results['monthly_comparison'][month_idx-1]['watches'] - if w['watch_id'] == watch_id][0] - inventory_start = prev_watch['inventory_end'] + # Calculate inventory and operations month by month + inventory_data = [] + for watch_id in df['watch_id'].unique(): + watch_df = df[df['watch_id'] == watch_id].sort_values('month') + inventory_start = 100 # Initial inventory - # Calculate what actually happens + for idx, row in watch_df.iterrows(): + production = int(row['predicted_demand']) available = inventory_start + production - units_sold = min(actual_demand, available) + units_sold = min(row['actual_demand'], available) inventory_end = available - units_sold - stockout = max(0, actual_demand - units_sold) + stockout = max(0, row['actual_demand'] - units_sold) - # Financial calculations - revenue = units_sold * watch['sell_price'] - production_cost = production * watch['base_cost'] - labor_cost = production * 20.0 - holding_cost = inventory_end * watch['base_cost'] * 0.02 + inventory_data.append({ + 'month': row['month'], + 'watch_id': row['watch_id'], + 'inventory_start': inventory_start, + 'production': production, + 'units_sold': units_sold, + 'inventory_end': inventory_end, + 'stockout': stockout + }) - # Lost revenue from stockouts - lost_revenue = stockout * watch['sell_price'] + inventory_start = inventory_end # Next month starts with this month's end - # Excess costs from overproduction - excess_inventory = max(0, inventory_end - 50) # 50 is healthy safety stock - excess_cost = excess_inventory * watch['base_cost'] * 0.05 # 5% waste/obsolescence + inventory_df = pd.DataFrame(inventory_data) - total_costs = production_cost + labor_cost + holding_cost + excess_cost - profit = revenue - total_costs + # Merge inventory data back to main df + df = df.merge(inventory_df, on=['month', 'watch_id'], how='left') - # Prediction error - error = abs(predicted_demand - actual_demand) - error_pct = (error / actual_demand * 100) if actual_demand > 0 else 0 + # Calculate financial metrics + df['revenue'] = df['units_sold'] * df['sell_price'] + df['production_cost'] = df['production'] * df['base_cost'] + df['labor_cost'] = df['production'] * 20.0 + df['holding_cost'] = df['inventory_end'] * df['base_cost'] * 0.02 + df['lost_revenue'] = df['stockout'] * df['sell_price'] - watch_result = { - 'watch_id': watch_id, - 'watch_name': watch['name'], - 'predicted_demand': predicted_demand, - 'actual_demand': actual_demand, - 'production': production, - 'inventory_start': inventory_start, - 'inventory_end': inventory_end, - 'units_sold': units_sold, - 'stockout': stockout, - 'revenue': round(revenue, 2), - 'production_cost': round(production_cost, 2), - 'labor_cost': round(labor_cost, 2), - 'holding_cost': round(holding_cost, 2), - 'excess_cost': round(excess_cost, 2), - 'lost_revenue': round(lost_revenue, 2), - 'total_costs': round(total_costs, 2), - 'profit': round(profit, 2), - 'error': error, - 'error_pct': round(error_pct, 1) - } + # Excess costs from overproduction (inventory > 50 safety stock) + df['excess_inventory'] = df['inventory_end'].apply(lambda x: max(0, x - 50)) + df['excess_cost'] = df['excess_inventory'] * df['base_cost'] * 0.05 - month_comparison['watches'].append(watch_result) - - # Accumulate totals - results['financial_summary']['total_revenue'] += revenue - results['financial_summary']['total_costs'] += total_costs - results['financial_summary']['total_profit'] += profit - results['financial_summary']['lost_revenue'] += lost_revenue - results['financial_summary']['excess_costs'] += excess_cost - - # Accumulate watch-level statistics - if watch_id not in results['watch_performance']: - results['watch_performance'][watch_id] = { - 'watch_name': watch['name'], - 'total_predicted': 0, - 'total_actual': 0, - 'total_sold': 0, - 'total_stockout': 0, - 'total_revenue': 0, - 'total_profit': 0, - 'actual_values': [], - 'predicted_values': [] - } - - perf = results['watch_performance'][watch_id] - perf['total_predicted'] += predicted_demand - perf['total_actual'] += actual_demand - perf['total_sold'] += units_sold - perf['total_stockout'] += stockout - perf['total_revenue'] += revenue - perf['total_profit'] += profit - perf['actual_values'].append(actual_demand) - perf['predicted_values'].append(predicted_demand) - - results['monthly_comparison'].append(month_comparison) - - # Round financial summary - for key in results['financial_summary']: - results['financial_summary'][key] = round(results['financial_summary'][key], 2) - - # Calculate prediction metrics using proper formulas - all_actual_values = [] - all_predicted_values = [] - - for watch_id, perf in results['watch_performance'].items(): - # Calculate MAPE for this watch using the proper formula - perf['mape'] = round(calculate_mape(perf['actual_values'], perf['predicted_values']), 1) - - # Calculate accuracy for this watch using the proper formula - perf['accuracy'] = round(calculate_accuracy(perf['actual_values'], perf['predicted_values']), 1) - - # Accumulate for overall metrics - all_actual_values.extend(perf['actual_values']) - all_predicted_values.extend(perf['predicted_values']) - - # Calculate overall metrics using proper formulas - overall_mape = calculate_mape(all_actual_values, all_predicted_values) - overall_accuracy = calculate_accuracy(all_actual_values, all_predicted_values) - - results['prediction_accuracy'] = { - 'mape': round(overall_mape, 1), - 'accuracy': round(overall_accuracy, 1) + df['total_costs'] = df['production_cost'] + df['labor_cost'] + df['holding_cost'] + df['excess_cost'] + df['profit'] = df['revenue'] - df['total_costs'] + + # Calculate prediction errors + df['error'] = (df['predicted_demand'] - df['actual_demand']).abs() + df['error_pct'] = df.apply( + lambda row: (row['error'] / row['actual_demand'] * 100) if row['actual_demand'] > 0 else 0, + axis=1 + ) + + # Round financial columns + financial_cols = ['revenue', 'production_cost', 'labor_cost', 'holding_cost', + 'excess_cost', 'lost_revenue', 'total_costs', 'profit'] + for col in financial_cols: + df[col] = df[col].round(2) + df['error_pct'] = df['error_pct'].round(1) + + # Calculate watch-level performance metrics + watch_performance = {} + for watch_id in df['watch_id'].unique(): + watch_df = df[df['watch_id'] == watch_id] + + actual_values = watch_df['actual_demand'].tolist() + predicted_values = watch_df['predicted_demand'].tolist() + + watch_performance[watch_id] = { + 'watch_name': watch_df['watch_name'].iloc[0], + 'total_predicted': int(watch_df['predicted_demand'].sum()), + 'total_actual': int(watch_df['actual_demand'].sum()), + 'total_sold': int(watch_df['units_sold'].sum()), + 'total_stockout': int(watch_df['stockout'].sum()), + 'total_revenue': float(watch_df['revenue'].sum()), + 'total_profit': float(watch_df['profit'].sum()), + 'mape': round(calculate_mape(actual_values, predicted_values), 1), + 'accuracy': round(calculate_accuracy(actual_values, predicted_values), 1) + } + + # Calculate overall prediction accuracy + all_actual = df['actual_demand'].tolist() + all_predicted = df['predicted_demand'].tolist() + + prediction_accuracy = { + 'mape': round(calculate_mape(all_actual, all_predicted), 1), + 'accuracy': round(calculate_accuracy(all_actual, all_predicted), 1) } - return results + # Calculate financial summary + financial_summary = { + 'total_revenue': round(df['revenue'].sum(), 2), + 'total_costs': round(df['total_costs'].sum(), 2), + 'total_profit': round(df['profit'].sum(), 2), + 'lost_revenue': round(df['lost_revenue'].sum(), 2), + 'excess_costs': round(df['excess_cost'].sum(), 2) + } + + # Build monthly comparison structure for templates + monthly_comparison = [] + for month in df['month'].unique(): + month_df = df[df['month'] == month] + + watches_list = [] + for _, row in month_df.iterrows(): + watches_list.append({ + 'watch_id': int(row['watch_id']), + 'watch_name': row['watch_name'], + 'predicted_demand': int(row['predicted_demand']), + 'actual_demand': int(row['actual_demand']), + 'production': int(row['production']), + 'inventory_start': int(row['inventory_start']), + 'inventory_end': int(row['inventory_end']), + 'units_sold': int(row['units_sold']), + 'stockout': int(row['stockout']), + 'revenue': float(row['revenue']), + 'production_cost': float(row['production_cost']), + 'labor_cost': float(row['labor_cost']), + 'holding_cost': float(row['holding_cost']), + 'excess_cost': float(row['excess_cost']), + 'lost_revenue': float(row['lost_revenue']), + 'total_costs': float(row['total_costs']), + 'profit': float(row['profit']), + 'error': float(row['error']), + 'error_pct': float(row['error_pct']) + }) + + monthly_comparison.append({ + 'month': int(month), + 'date': month_df['date'].iloc[0], + 'watches': watches_list + }) + + return { + 'monthly_comparison': monthly_comparison, + 'watch_performance': watch_performance, + 'financial_summary': financial_summary, + 'prediction_accuracy': prediction_accuracy, + 'dataframe': df # Include for potential future use + } @app.route('/') -- GitLab From 2b6956f310f22d4eb0faac893db925495f712a8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 10:27:35 +0000 Subject: [PATCH 5/8] Remove DataFrame from results dict to fix JSON serialization error --- forecast_app/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index e6727dd..960a7a7 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -278,8 +278,7 @@ def calculate_results(predictions, test_data): 'monthly_comparison': monthly_comparison, 'watch_performance': watch_performance, 'financial_summary': financial_summary, - 'prediction_accuracy': prediction_accuracy, - 'dataframe': df # Include for potential future use + 'prediction_accuracy': prediction_accuracy } -- GitLab From ae9732afca2d105a021bd0c6582e5c579d742962 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 10:34:43 +0000 Subject: [PATCH 6/8] Convert numpy types to Python native types for JSON serialization --- forecast_app/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index 960a7a7..4530569 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -209,7 +209,7 @@ def calculate_results(predictions, test_data): actual_values = watch_df['actual_demand'].tolist() predicted_values = watch_df['predicted_demand'].tolist() - watch_performance[watch_id] = { + watch_performance[int(watch_id)] = { # Convert numpy.int64 to Python int 'watch_name': watch_df['watch_name'].iloc[0], 'total_predicted': int(watch_df['predicted_demand'].sum()), 'total_actual': int(watch_df['actual_demand'].sum()), @@ -241,7 +241,7 @@ def calculate_results(predictions, test_data): # Build monthly comparison structure for templates monthly_comparison = [] - for month in df['month'].unique(): + for month in sorted(df['month'].unique()): # Sort months month_df = df[df['month'] == month] watches_list = [] -- GitLab From 7fc9d59a3ca026299d9a1dcaf4abfca94c0b32c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 10:47:07 +0000 Subject: [PATCH 7/8] Add smart customer-based data generator with behavior simulation --- forecast_app/smart_data_generator.py | 536 +++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 forecast_app/smart_data_generator.py diff --git a/forecast_app/smart_data_generator.py b/forecast_app/smart_data_generator.py new file mode 100644 index 0000000..7aaa8f1 --- /dev/null +++ b/forecast_app/smart_data_generator.py @@ -0,0 +1,536 @@ +""" +Smart Data Generator for Supply Chain Forecasting Educational App + +Generates realistic supply chain data driven by actual customer behavior simulation. +Customers with different segments, purchase patterns, and preferences create +bottom-up demand that exhibits realistic time series properties (trends, seasonality). +""" + +import numpy as np +import json +from datetime import datetime, timedelta +from typing import Dict, List, Tuple +from dataclasses import dataclass + + +@dataclass +class Customer: + """Represents an individual customer with purchase behavior""" + id: int + segment: str # 'luxury', 'sport', 'casual' + purchase_frequency: float # Avg months between purchases + brand_affinity: Dict[int, float] # Preference for each watch model (0-1) + price_sensitivity: float # How much price affects decision (0-1) + seasonality_factor: Dict[int, float] # Month-specific purchase probability multipliers + lifetime_value: float # Expected total purchases + + def will_purchase_this_month(self, month: int, base_prob: float) -> bool: + """Determine if customer will purchase this month""" + # Base probability from purchase frequency + monthly_prob = base_prob / self.purchase_frequency + + # Apply seasonality + month_of_year = (month % 12) + 1 + seasonal_mult = self.seasonality_factor.get(month_of_year, 1.0) + + # Random decision + return np.random.random() < (monthly_prob * seasonal_mult) + + def choose_watch(self, available_watches: List[Dict]) -> int: + """Choose which watch to buy based on affinity""" + # Calculate weighted probabilities + probs = [] + watch_ids = [] + + for watch in available_watches: + watch_id = watch['id'] + affinity = self.brand_affinity.get(watch_id, 0.1) + + # Price sensitivity affects choice + price_factor = 1.0 - (self.price_sensitivity * (watch['sell_price'] / 1000)) + price_factor = max(0.1, price_factor) + + probs.append(affinity * price_factor) + watch_ids.append(watch_id) + + # Normalize probabilities + probs = np.array(probs) + probs = probs / probs.sum() + + # Choose watch + return np.random.choice(watch_ids, p=probs) + + +class CustomerSegment: + """Defines a customer segment with shared characteristics""" + + def __init__(self, name: str, size: int, config: Dict): + self.name = name + self.size = size + self.config = config + self.customers: List[Customer] = [] + + def generate_customers(self, start_id: int) -> List[Customer]: + """Generate customers for this segment""" + customers = [] + + for i in range(self.size): + customer_id = start_id + i + + # Sample from segment distributions + purchase_freq = np.random.normal( + self.config['purchase_frequency_mean'], + self.config['purchase_frequency_std'] + ) + purchase_freq = max(1.0, purchase_freq) # At least once per year + + price_sensitivity = np.random.beta( + self.config['price_sensitivity_alpha'], + self.config['price_sensitivity_beta'] + ) + + # Brand affinity (different customers prefer different watches) + brand_affinity = {} + for watch_id, affinity_params in self.config['brand_affinity'].items(): + brand_affinity[watch_id] = np.random.beta( + affinity_params['alpha'], + affinity_params['beta'] + ) + + customer = Customer( + id=customer_id, + segment=self.name, + purchase_frequency=purchase_freq, + brand_affinity=brand_affinity, + price_sensitivity=price_sensitivity, + seasonality_factor=self.config['seasonality_factor'], + lifetime_value=self.config['lifetime_value'] + ) + + customers.append(customer) + + self.customers = customers + return customers + + +class SmartSupplyChainDataGenerator: + """Generates realistic supply chain data using customer behavior simulation""" + + def __init__(self, seed: int = 42): + """ + Initialize the smart data generator + + Args: + seed: Random seed for reproducibility + """ + np.random.seed(seed) + + # Define the 3 watch models (same as original) + self.watches = [ + { + 'id': 1, + 'name': 'Luxury Classic', + 'category': 'luxury', + 'base_cost': 150.0, + 'sell_price': 500.0, + 'base_demand': 80, + 'peak_months': [11, 12, 1] + }, + { + 'id': 2, + 'name': 'Sport Pro', + 'category': 'sport', + 'base_cost': 80.0, + 'sell_price': 220.0, + 'base_demand': 150, + 'peak_months': [4, 5, 6, 7] + }, + { + 'id': 3, + 'name': 'Casual Style', + 'category': 'casual', + 'base_cost': 40.0, + 'sell_price': 120.0, + 'base_demand': 200, + 'peak_months': [9, 10] + } + ] + + # Define customer segments + self.segment_configs = { + 'luxury_buyers': { + 'size': 300, + 'purchase_frequency_mean': 18.0, # Buy every 18 months + 'purchase_frequency_std': 6.0, + 'price_sensitivity_alpha': 2, + 'price_sensitivity_beta': 8, # Less price sensitive + 'brand_affinity': { + 1: {'alpha': 8, 'beta': 2}, # Strong preference for luxury + 2: {'alpha': 3, 'beta': 7}, + 3: {'alpha': 2, 'beta': 8} + }, + 'seasonality_factor': {11: 1.5, 12: 1.8, 1: 1.3}, # Holiday boost + 'lifetime_value': 2000 + }, + 'sport_enthusiasts': { + 'size': 500, + 'purchase_frequency_mean': 14.0, # Buy every 14 months + 'purchase_frequency_std': 5.0, + 'price_sensitivity_alpha': 4, + 'price_sensitivity_beta': 6, + 'brand_affinity': { + 1: {'alpha': 2, 'beta': 8}, + 2: {'alpha': 8, 'beta': 2}, # Strong preference for sport + 3: {'alpha': 4, 'beta': 6} + }, + 'seasonality_factor': {4: 1.3, 5: 1.4, 6: 1.5, 7: 1.4}, # Spring/summer + 'lifetime_value': 800 + }, + 'casual_shoppers': { + 'size': 800, + 'purchase_frequency_mean': 10.0, # Buy every 10 months + 'purchase_frequency_std': 4.0, + 'price_sensitivity_alpha': 6, + 'price_sensitivity_beta': 4, # More price sensitive + 'brand_affinity': { + 1: {'alpha': 2, 'beta': 8}, + 2: {'alpha': 4, 'beta': 6}, + 3: {'alpha': 7, 'beta': 3} # Strong preference for casual + }, + 'seasonality_factor': {9: 1.3, 10: 1.4}, # Back to school + 'lifetime_value': 500 + } + } + + # Generate customer base + self.customers = self._generate_customer_base() + + # Track customer growth over time (new customers join, some churn) + self.customer_growth_rate = 0.005 # 0.5% monthly growth + self.churn_rate = 0.003 # 0.3% monthly churn + + def _generate_customer_base(self) -> List[Customer]: + """Generate initial customer base from segments""" + all_customers = [] + current_id = 1 + + for segment_name, config in self.segment_configs.items(): + segment = CustomerSegment(segment_name, config['size'], config) + customers = segment.generate_customers(current_id) + all_customers.extend(customers) + current_id += len(customers) + + return all_customers + + def _simulate_monthly_purchases(self, month_idx: int, + active_customers: List[Customer]) -> Dict[int, int]: + """ + Simulate customer purchases for a given month + + Args: + month_idx: Current month index + active_customers: List of active customers + + Returns: + Dictionary of {watch_id: purchase_count} + """ + purchases = {watch['id']: 0 for watch in self.watches} + + # Apply trend - base probability increases over time + trend_factor = 1.0 + (0.002 * month_idx) # 0.2% monthly increase + base_purchase_prob = 0.08 * trend_factor + + for customer in active_customers: + if customer.will_purchase_this_month(month_idx, base_purchase_prob): + watch_id = customer.choose_watch(self.watches) + purchases[watch_id] += 1 + + return purchases + + def _update_customer_base(self, month_idx: int) -> List[Customer]: + """Update customer base with growth and churn""" + # Remove churned customers + active_customers = [] + for customer in self.customers: + if np.random.random() > self.churn_rate: + active_customers.append(customer) + + # Add new customers (maintaining segment proportions) + new_customers_count = int(len(active_customers) * self.customer_growth_rate) + + if new_customers_count > 0: + # Distribute new customers across segments + segment_names = list(self.segment_configs.keys()) + segment_sizes = [self.segment_configs[s]['size'] for s in segment_names] + total_size = sum(segment_sizes) + segment_probs = [s / total_size for s in segment_sizes] + + next_id = max(c.id for c in active_customers) + 1 + + for _ in range(new_customers_count): + # Choose segment + segment_name = np.random.choice(segment_names, p=segment_probs) + config = self.segment_configs[segment_name] + + # Create new customer + segment = CustomerSegment(segment_name, 1, config) + new_customer = segment.generate_customers(next_id)[0] + active_customers.append(new_customer) + next_id += 1 + + self.customers = active_customers + return active_customers + + def _calculate_costs_and_revenue(self, watch: Dict, demand: int, + production: int, inventory_start: int) -> Dict: + """ + Calculate monthly costs and revenue based on production decisions + + Args: + watch: Watch model configuration + demand: Actual customer demand + production: Units produced + inventory_start: Starting inventory + + Returns: + Dictionary with financial metrics + """ + # Calculate what we can actually sell + available_units = inventory_start + production + units_sold = min(demand, available_units) + + # Calculate ending inventory + inventory_end = available_units - units_sold + + # Revenue from sales + revenue = units_sold * watch['sell_price'] + + # Costs + production_cost = production * watch['base_cost'] + labor_cost = production * 20.0 + holding_cost = inventory_end * watch['base_cost'] * 0.02 + + # Stockout cost + stockout_units = max(0, demand - units_sold) + stockout_cost = stockout_units * watch['sell_price'] * 0.3 + + total_costs = production_cost + labor_cost + holding_cost + stockout_cost + profit = revenue - total_costs + + return { + 'demand': int(demand), + 'production': production, + 'inventory_start': inventory_start, + 'inventory_end': inventory_end, + 'units_sold': int(units_sold), + 'stockout_units': int(stockout_units), + 'revenue': round(revenue, 2), + 'production_cost': round(production_cost, 2), + 'labor_cost': round(labor_cost, 2), + 'holding_cost': round(holding_cost, 2), + 'stockout_cost': round(stockout_cost, 2), + 'total_costs': round(total_costs, 2), + 'profit': round(profit, 2) + } + + def generate_dataset(self, years: int = 11) -> Dict: + """ + Generate complete dataset for specified number of years + + Args: + years: Number of years to generate + + Returns: + Dictionary containing all historical data + """ + total_months = years * 12 + start_date = datetime(2014, 1, 1) + + dataset = { + 'metadata': { + 'generated_date': datetime.now().isoformat(), + 'generator_type': 'smart_customer_simulation', + 'years': years, + 'total_months': total_months, + 'start_date': start_date.isoformat(), + 'initial_customers': len(self.customers), + 'watches': self.watches + }, + 'historical_data': [] + } + + # Track inventory for each watch + inventory = {watch['id']: 100 for watch in self.watches} + + # Generate data month by month + for month_idx in range(total_months): + current_date = start_date + timedelta(days=30 * month_idx) + year = (month_idx // 12) + 1 + month_in_year = (month_idx % 12) + 1 + + # Update customer base (growth and churn) + active_customers = self._update_customer_base(month_idx) + + # Simulate customer purchases + purchases = self._simulate_monthly_purchases(month_idx, active_customers) + + month_data = { + 'month_index': month_idx, + 'year': year, + 'month': month_in_year, + 'date': current_date.strftime('%Y-%m'), + 'active_customers': len(active_customers), + 'watches': [] + } + + # Process each watch + for watch in self.watches: + watch_id = watch['id'] + demand = purchases[watch_id] + + # Production strategy: produce based on demand + safety stock + production = int(demand * 1.05) + + # Calculate financials + watch_data = self._calculate_costs_and_revenue( + watch, demand, production, inventory[watch_id] + ) + + # Update inventory for next month + inventory[watch_id] = watch_data['inventory_end'] + + # Add watch info + watch_data['watch_id'] = watch['id'] + watch_data['watch_name'] = watch['name'] + + month_data['watches'].append(watch_data) + + dataset['historical_data'].append(month_data) + + # Add final customer statistics + dataset['metadata']['final_customers'] = len(self.customers) + + return dataset + + def save_dataset(self, dataset: Dict, filepath: str = 'supply_chain_data.json'): + """Save dataset to JSON file""" + with open(filepath, 'w') as f: + json.dump(dataset, f, indent=2) + print(f"Dataset saved to {filepath}") + + def get_training_data(self, dataset: Dict, training_years: int = 10) -> Dict: + """Extract training data (first N years) from full dataset""" + training_months = training_years * 12 + + training_data = { + 'metadata': dataset['metadata'].copy(), + 'historical_data': dataset['historical_data'][:training_months] + } + training_data['metadata']['years'] = training_years + training_data['metadata']['total_months'] = training_months + training_data['metadata']['note'] = f"Training data: first {training_years} years" + + return training_data + + def get_test_data(self, dataset: Dict, test_year: int = 11) -> List[Dict]: + """Extract test data (year to predict)""" + start_idx = (test_year - 1) * 12 + end_idx = test_year * 12 + + return dataset['historical_data'][start_idx:end_idx] + + +def main(): + """Generate and save the dataset""" + print("=" * 60) + print("Smart Supply Chain Dataset Generator") + print("Customer Behavior Simulation") + print("=" * 60) + + generator = SmartSupplyChainDataGenerator(seed=42) + + print(f"\nInitial Customer Base: {len(generator.customers)} customers") + print("\nCustomer Segments:") + for segment_name, config in generator.segment_configs.items(): + print(f" - {segment_name}: {config['size']} customers") + + print("\n" + "-" * 60) + print("Generating 11 years of data...") + print("-" * 60) + + # Generate full 11-year dataset + full_dataset = generator.generate_dataset(years=11) + + # Save full dataset + generator.save_dataset(full_dataset, 'data/sim_supply_chain_data_full.json') + + # Save training data (10 years) + training_data = generator.get_training_data(full_dataset, training_years=10) + generator.save_dataset(training_data, 'data/sim_supply_chain_data_training.json') + + # Save test data (year 11) + test_data = generator.get_test_data(full_dataset, test_year=11) + with open('data/sim_supply_chain_data_test.json', 'w') as f: + json.dump(test_data, f, indent=2) + + print("\n" + "=" * 60) + print("Dataset Generation Complete!") + print("=" * 60) + print(f"Total months: {len(full_dataset['historical_data'])}") + print(f"Training months: {len(training_data['historical_data'])}") + print(f"Test months: {len(test_data)}") + print(f"Final customer base: {full_dataset['metadata']['final_customers']} customers") + + # Print sample statistics + print("\n" + "=" * 60) + print("Sample Statistics:") + print("=" * 60) + + # Year 1 stats + print("\nYear 1:") + year1_data = full_dataset['historical_data'][:12] + for watch in full_dataset['metadata']['watches']: + watch_id = watch['id'] + watch_name = watch['name'] + + demands = [m['watches'][watch_id-1]['demand'] for m in year1_data] + revenues = [m['watches'][watch_id-1]['revenue'] for m in year1_data] + + print(f" {watch_name}:") + print(f" Avg Monthly Demand: {np.mean(demands):.1f} units") + print(f" Total Annual Demand: {np.sum(demands)} units") + print(f" Annual Revenue: CHF {np.sum(revenues):,.2f}") + + # Year 10 stats (showing growth) + print("\nYear 10:") + year10_data = full_dataset['historical_data'][108:120] + for watch in full_dataset['metadata']['watches']: + watch_id = watch['id'] + watch_name = watch['name'] + + demands = [m['watches'][watch_id-1]['demand'] for m in year10_data] + revenues = [m['watches'][watch_id-1]['revenue'] for m in year10_data] + + print(f" {watch_name}:") + print(f" Avg Monthly Demand: {np.mean(demands):.1f} units") + print(f" Total Annual Demand: {np.sum(demands)} units") + print(f" Annual Revenue: CHF {np.sum(revenues):,.2f}") + + # Calculate growth rates + print("\n" + "=" * 60) + print("Growth Analysis (Year 1 → Year 10):") + print("=" * 60) + for watch in full_dataset['metadata']['watches']: + watch_id = watch['id'] + watch_name = watch['name'] + + y1_demand = sum([m['watches'][watch_id-1]['demand'] for m in year1_data]) + y10_demand = sum([m['watches'][watch_id-1]['demand'] for m in year10_data]) + + growth = ((y10_demand - y1_demand) / y1_demand) * 100 + + print(f" {watch_name}: {growth:+.1f}% growth") + + +if __name__ == "__main__": + main() -- GitLab From e4b6f3e9a5475979bdf51f36d93d252e2244855c Mon Sep 17 00:00:00 2001 From: "thibault.barthelet" Date: Wed, 26 Nov 2025 13:01:54 +0100 Subject: [PATCH 8/8] working yayy --- forecast_app/app.py | 4 ++-- forecast_app/smart_data_generator.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/forecast_app/app.py b/forecast_app/app.py index 4530569..1749a9d 100644 --- a/forecast_app/app.py +++ b/forecast_app/app.py @@ -26,8 +26,8 @@ app.config['SESSION_TYPE'] = 'filesystem' # Load dataset DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') -TRAINING_DATA_PATH = os.path.join(DATA_DIR, 'sim_supply_chain_data_training.json') -TEST_DATA_PATH = os.path.join(DATA_DIR, 'sim_supply_chain_data_test.json') +TRAINING_DATA_PATH = os.path.join(DATA_DIR, 'sim2_supply_chain_data_training.json') +TEST_DATA_PATH = os.path.join(DATA_DIR, 'sim2_supply_chain_data_test.json') def load_training_data(): diff --git a/forecast_app/smart_data_generator.py b/forecast_app/smart_data_generator.py index 7aaa8f1..bed9d24 100644 --- a/forecast_app/smart_data_generator.py +++ b/forecast_app/smart_data_generator.py @@ -159,7 +159,7 @@ class SmartSupplyChainDataGenerator: # Define customer segments self.segment_configs = { 'luxury_buyers': { - 'size': 300, + 'size': 30000, 'purchase_frequency_mean': 18.0, # Buy every 18 months 'purchase_frequency_std': 6.0, 'price_sensitivity_alpha': 2, @@ -173,7 +173,7 @@ class SmartSupplyChainDataGenerator: 'lifetime_value': 2000 }, 'sport_enthusiasts': { - 'size': 500, + 'size': 50000, 'purchase_frequency_mean': 14.0, # Buy every 14 months 'purchase_frequency_std': 5.0, 'price_sensitivity_alpha': 4, @@ -187,7 +187,7 @@ class SmartSupplyChainDataGenerator: 'lifetime_value': 800 }, 'casual_shoppers': { - 'size': 800, + 'size': 80000, 'purchase_frequency_mean': 10.0, # Buy every 10 months 'purchase_frequency_std': 4.0, 'price_sensitivity_alpha': 6, @@ -462,15 +462,15 @@ def main(): full_dataset = generator.generate_dataset(years=11) # Save full dataset - generator.save_dataset(full_dataset, 'data/sim_supply_chain_data_full.json') + generator.save_dataset(full_dataset, 'data/sim2_supply_chain_data_full.json') # Save training data (10 years) training_data = generator.get_training_data(full_dataset, training_years=10) - generator.save_dataset(training_data, 'data/sim_supply_chain_data_training.json') + generator.save_dataset(training_data, 'data/sim2_supply_chain_data_training.json') # Save test data (year 11) test_data = generator.get_test_data(full_dataset, test_year=11) - with open('data/sim_supply_chain_data_test.json', 'w') as f: + with open('data/sim2_supply_chain_data_test.json', 'w') as f: json.dump(test_data, f, indent=2) print("\n" + "=" * 60) -- GitLab