NavyDevilDoc commited on
Commit
7e6495e
Β·
verified Β·
1 Parent(s): 42662d7

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +132 -545
src/streamlit_app.py CHANGED
@@ -15,245 +15,29 @@ def main():
15
  st.title("βš–οΈ Multi-Criteria Decision Analysis (MCDA) Calculator")
16
  st.markdown("Compare products and alternatives using weighted criteria analysis")
17
 
18
- # Sidebar for configuration
19
- st.sidebar.header("πŸ“‹ Configuration")
20
-
21
- # Step 1: Choose input method
22
- input_method = st.sidebar.radio(
23
- "How would you like to input data?",
24
- ["Manual Entry", "Upload Excel File"]
25
- )
26
-
27
- if input_method == "Upload Excel File":
28
- excel_interface()
29
- else:
30
- manual_interface()
31
-
32
- def excel_interface():
33
- """Handle Excel file upload with improved HF Spaces compatibility."""
34
- st.header("πŸ“ Excel File Analysis")
35
-
36
- # Add file upload instructions
37
- st.info("πŸ’‘ Upload an Excel file with 'Config' and 'Data' sheets.")
38
-
39
- uploaded_file = st.file_uploader(
40
- "Upload Excel file with Config and Data sheets",
41
- type=['xlsx', 'xls'],
42
- help="Excel file should have 'Config' sheet (category, maximize) and 'Data' sheet (name, category values)",
43
- key="excel_uploader"
44
- )
45
-
46
- if uploaded_file is not None:
47
- st.success(f"βœ… File uploaded: **{uploaded_file.name}**")
48
-
49
- # Show file details
50
- col1, col2, col3 = st.columns(3)
51
- with col1:
52
- st.metric("File Size", f"{uploaded_file.size:,} bytes")
53
- with col2:
54
- st.metric("File Type", uploaded_file.type)
55
- with col3:
56
- process_button = st.button("πŸš€ Process File", type="primary", use_container_width=True)
57
-
58
- if process_button:
59
- with st.spinner("πŸ”„ Processing Excel file..."):
60
- try:
61
- # Reset file pointer
62
- uploaded_file.seek(0)
63
-
64
- # Try different engines for better compatibility
65
- try:
66
- # First try openpyxl (preferred for .xlsx)
67
- config_df = pd.read_excel(uploaded_file, sheet_name='Config', engine='openpyxl')
68
- uploaded_file.seek(0) # Reset again
69
- data_df = pd.read_excel(uploaded_file, sheet_name='Data', engine='openpyxl')
70
- except Exception as e1:
71
- st.warning(f"openpyxl failed: {e1}. Trying xlrd...")
72
- try:
73
- # Fallback to xlrd (for .xls files)
74
- uploaded_file.seek(0)
75
- config_df = pd.read_excel(uploaded_file, sheet_name='Config', engine='xlrd')
76
- uploaded_file.seek(0)
77
- data_df = pd.read_excel(uploaded_file, sheet_name='Data', engine='xlrd')
78
- except Exception as e2:
79
- st.error(f"Both engines failed. openpyxl: {e1}, xlrd: {e2}")
80
- # Try without specifying engine
81
- uploaded_file.seek(0)
82
- config_df = pd.read_excel(uploaded_file, sheet_name='Config')
83
- uploaded_file.seek(0)
84
- data_df = pd.read_excel(uploaded_file, sheet_name='Data')
85
-
86
- st.success("βœ… Successfully read Excel sheets")
87
-
88
- # Validate required columns
89
- if 'category' not in config_df.columns or 'maximize' not in config_df.columns:
90
- st.error("❌ Config sheet must have 'category' and 'maximize' columns")
91
- return
92
-
93
- if 'name' not in data_df.columns:
94
- st.error("❌ Data sheet must have 'name' column")
95
- return
96
-
97
- # Show preview of what we read
98
- with st.expander("πŸ“‹ File Contents Preview", expanded=True):
99
- col1, col2 = st.columns(2)
100
- with col1:
101
- st.write("**Config Sheet:**")
102
- st.dataframe(config_df, use_container_width=True)
103
- with col2:
104
- st.write("**Data Sheet:**")
105
- st.dataframe(data_df.head(), use_container_width=True)
106
-
107
- # Parse categories and maximize settings
108
- categories = config_df['category'].tolist()
109
- maximize_values = config_df['maximize'].tolist()
110
- maximize = dict(zip(categories, maximize_values))
111
-
112
- # Validate that all categories exist in data
113
- missing_categories = [cat for cat in categories if cat not in data_df.columns]
114
- if missing_categories:
115
- st.error(f"❌ Missing categories in Data sheet: {missing_categories}")
116
- return
117
-
118
- # Create calculator
119
- calc = UtilityCalculator(categories, maximize)
120
-
121
- # Add products from data
122
- for _, row in data_df.iterrows():
123
- product_name = row['name']
124
- scores = {cat: row[cat] for cat in categories if cat in row and pd.notna(row[cat])}
125
-
126
- # Check for missing values
127
- if len(scores) != len(categories):
128
- missing = [cat for cat in categories if cat not in scores]
129
- st.warning(f"⚠️ Product '{product_name}' missing values for: {missing}")
130
-
131
- calc.add_product(product_name, scores)
132
-
133
- st.success(f"βœ… Successfully created calculator with {len(calc.products)} products and {len(calc.categories)} categories")
134
-
135
- # Store in session state
136
- st.session_state['excel_calculator'] = calc
137
- st.session_state['excel_filename'] = uploaded_file.name
138
-
139
- # Display configuration
140
- st.subheader("πŸ“‹ Configuration")
141
- config_display_df = pd.DataFrame({
142
- 'Category': calc.categories,
143
- 'Optimize': ['Maximize' if calc.maximize[cat] else 'Minimize' for cat in calc.categories],
144
- 'Weight': [calc.weights[cat] for cat in calc.categories]
145
- })
146
- st.dataframe(config_display_df, use_container_width=True)
147
-
148
- # Weight adjustment
149
- adjust_weights(calc)
150
-
151
- # Results
152
- display_results(calc)
153
-
154
- except Exception as e:
155
- st.error(f"❌ Error processing Excel file: {str(e)}")
156
-
157
- # Enhanced debugging
158
- with st.expander("πŸ› Detailed Error Information"):
159
- st.write("**Error Details:**")
160
- st.write(f"- Error type: {type(e).__name__}")
161
- st.write(f"- Error message: {str(e)}")
162
-
163
- # Try to diagnose the issue
164
- try:
165
- uploaded_file.seek(0)
166
- # Check available pandas Excel engines
167
- st.write("**Available pandas engines:**")
168
- try:
169
- import openpyxl
170
- st.write("βœ… openpyxl available")
171
- except ImportError:
172
- st.write("❌ openpyxl not available")
173
-
174
- # Try to read sheet names
175
- excel_file = pd.ExcelFile(uploaded_file)
176
- st.write(f"- Available sheets: {excel_file.sheet_names}")
177
-
178
- # Check if required sheets exist
179
- if 'Config' not in excel_file.sheet_names:
180
- st.error("❌ 'Config' sheet not found")
181
- if 'Data' not in excel_file.sheet_names:
182
- st.error("❌ 'Data' sheet not found")
183
-
184
- except Exception as diag_error:
185
- st.write(f"- Could not diagnose: {diag_error}")
186
-
187
- import traceback
188
- st.code(traceback.format_exc())
189
-
190
- # Display results if calculator is in session state (from previous processing)
191
- elif 'excel_calculator' in st.session_state:
192
- calc = st.session_state['excel_calculator']
193
- filename = st.session_state.get('excel_filename', 'Previous file')
194
 
195
- st.info(f"πŸ“Š Currently analyzing: **{filename}**")
 
 
 
 
196
 
197
- if st.button("πŸ”„ Clear and Upload New File"):
198
- if 'excel_calculator' in st.session_state:
199
- del st.session_state['excel_calculator']
200
- if 'excel_filename' in st.session_state:
201
- del st.session_state['excel_filename']
202
- st.rerun()
203
-
204
- # Display configuration
205
- st.subheader("πŸ“‹ Configuration")
206
- config_df = pd.DataFrame({
207
- 'Category': calc.categories,
208
- 'Optimize': ['Maximize' if calc.maximize[cat] else 'Minimize' for cat in calc.categories],
209
- 'Weight': [calc.weights[cat] for cat in calc.categories]
210
- })
211
- st.dataframe(config_df, use_container_width=True)
212
-
213
- # Weight adjustment
214
- adjust_weights(calc)
215
-
216
- # Results
217
- display_results(calc)
218
 
219
- else:
220
- st.info("πŸ“€ Please upload an Excel file and click 'Process File' to begin analysis")
221
-
222
- # Add sample download option
223
- if st.button("πŸ“₯ Download Sample Excel Template"):
224
- # Create sample data
225
- sample_config = pd.DataFrame({
226
- 'category': ['Performance', 'Cost', 'Quality', 'Reliability'],
227
- 'maximize': [True, False, True, True]
228
- })
229
-
230
- sample_data = pd.DataFrame({
231
- 'name': ['Product_A', 'Product_B', 'Product_C', 'Product_D'],
232
- 'Performance': [85, 70, 90, 75],
233
- 'Cost': [120, 100, 140, 110],
234
- 'Quality': [90, 85, 95, 80],
235
- 'Reliability': [88, 92, 85, 90]
236
- })
237
-
238
- # Create Excel file in memory
239
- import io
240
- excel_buffer = io.BytesIO()
241
- with pd.ExcelWriter(excel_buffer, engine='openpyxl') as writer:
242
- sample_config.to_excel(writer, sheet_name='Config', index=False)
243
- sample_data.to_excel(writer, sheet_name='Data', index=False)
244
-
245
- excel_buffer.seek(0)
246
-
247
- st.download_button(
248
- label="πŸ“ Download Sample.xlsx",
249
- data=excel_buffer.getvalue(),
250
- file_name="MCDA_Sample_Template.xlsx",
251
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
252
- )
253
 
254
  def manual_interface():
255
  """Handle manual data entry interface."""
256
- st.header("✏️ Manual Data Entry")
257
 
258
  # Step 1: Define categories
259
  st.subheader("1. Define Categories")
@@ -265,7 +49,7 @@ def manual_interface():
265
  "Enter categories (one per line):",
266
  value="Performance\nCost\nReliability",
267
  height=100,
268
- help="Enter each category on a new line"
269
  )
270
  categories = [cat.strip() for cat in categories_input.split('\n') if cat.strip()]
271
 
@@ -341,33 +125,33 @@ def data_entry_interface(categories):
341
  if 'products' not in st.session_state:
342
  st.session_state.products = []
343
 
344
- # Add new product form
345
- with st.expander("βž• Add New Product", expanded=len(st.session_state.products) == 0):
346
- with st.form("add_product"):
347
- col1, col2 = st.columns([1, 2])
348
-
349
- with col1:
350
- product_name = st.text_input("Product Name", placeholder="e.g., Product A")
351
-
352
- with col2:
353
- scores = {}
354
- cols = st.columns(len(categories))
355
- for i, cat in enumerate(categories):
356
- with cols[i]:
357
- scores[cat] = st.number_input(f"{cat}", value=0.0, step=1.0)
358
-
359
- submitted = st.form_submit_button("Add Product")
360
-
361
- if submitted and product_name:
362
- # Check if product name already exists
363
- existing_names = [p['name'] for p in st.session_state.products]
364
- if product_name in existing_names:
365
- st.error(f"❌ Product '{product_name}' already exists. Please use a different name.")
366
- else:
367
- new_product = {'name': product_name, **scores}
368
- st.session_state.products.append(new_product)
369
- st.success(f"βœ… Added {product_name}")
370
- st.rerun()
371
 
372
  # Display current products with edit/delete options
373
  if st.session_state.products:
@@ -381,124 +165,105 @@ def data_entry_interface(categories):
381
  edited_df = st.data_editor(
382
  df,
383
  use_container_width=True,
384
- num_rows="dynamic", # This should allow adding/deleting rows
385
- key="products_editor"
 
 
 
 
386
  )
387
 
388
  # Update session state with edited data
389
  st.session_state.products = edited_df.to_dict('records')
390
 
391
- # Individual product management
392
- st.write("**Manage Individual Products:**")
393
-
394
- col1, col2, col3 = st.columns([2, 1, 1])
395
 
396
  with col1:
397
- # Select product to edit/delete
398
- if st.session_state.products:
399
- product_names = [p['name'] for p in st.session_state.products]
400
- selected_product = st.selectbox(
401
- "Select product to manage:",
402
- options=product_names,
403
- key="product_selector"
404
- )
405
-
406
- with col2:
407
- # Edit button
408
- if st.button("✏️ Edit Selected", key="edit_button"):
409
- if 'edit_mode' not in st.session_state:
410
  st.session_state.edit_mode = {}
411
- st.session_state.edit_mode[selected_product] = True
412
  st.rerun()
413
 
414
- with col3:
415
- # Delete button
416
- if st.button("πŸ—‘οΈ Delete Selected", key="delete_button", type="secondary"):
417
- st.session_state.products = [p for p in st.session_state.products if p['name'] != selected_product]
418
- st.success(f"βœ… Deleted {selected_product}")
419
- st.rerun()
420
-
421
- # Edit mode for selected product
422
- if 'edit_mode' in st.session_state and selected_product in st.session_state.edit_mode:
423
- if st.session_state.edit_mode[selected_product]:
424
-
425
- st.write(f"**Editing: {selected_product}**")
426
-
427
- # Find the product to edit
428
- product_to_edit = next(p for p in st.session_state.products if p['name'] == selected_product)
429
-
430
- with st.form(f"edit_product_{selected_product}"):
431
- col1, col2 = st.columns([1, 2])
432
-
433
- with col1:
434
- new_name = st.text_input("Product Name", value=selected_product)
435
-
436
- with col2:
437
- new_scores = {}
438
- cols = st.columns(len(categories))
439
- for i, cat in enumerate(categories):
440
- with cols[i]:
441
- current_value = product_to_edit.get(cat, 0.0)
442
- new_scores[cat] = st.number_input(
443
- f"{cat}",
444
- value=float(current_value),
445
- step=1.0,
446
- key=f"edit_{cat}_{selected_product}"
447
- )
448
-
449
- col_save, col_cancel = st.columns(2)
450
-
451
- with col_save:
452
- save_changes = st.form_submit_button("πŸ’Ύ Save Changes", type="primary")
453
-
454
- with col_cancel:
455
- cancel_edit = st.form_submit_button("❌ Cancel")
456
-
457
- if save_changes:
458
- # Update the product
459
- for i, product in enumerate(st.session_state.products):
460
- if product['name'] == selected_product:
461
- st.session_state.products[i] = {'name': new_name, **new_scores}
462
- break
463
-
464
- # Clear edit mode
465
- st.session_state.edit_mode[selected_product] = False
466
- st.success(f"βœ… Updated product: {new_name}")
467
- st.rerun()
468
-
469
- if cancel_edit:
470
- # Clear edit mode
471
- st.session_state.edit_mode[selected_product] = False
472
- st.rerun()
473
 
474
- # Bulk operations
475
- if len(st.session_state.products) > 0:
476
- st.write("**Bulk Operations:**")
477
- col1, col2 = st.columns(2)
478
-
479
- with col1:
480
- if st.button("πŸ—‘οΈ Clear All Products", type="secondary"):
481
- st.session_state.products = []
482
- if 'edit_mode' in st.session_state:
483
- st.session_state.edit_mode = {}
484
- st.success("βœ… Cleared all products")
485
- st.rerun()
486
-
487
- with col2:
488
- # Export current products to JSON for backup
489
- import json
490
- products_json = json.dumps(st.session_state.products, indent=2)
491
  st.download_button(
492
- label="πŸ“₯ Export Products (JSON)",
493
- data=products_json,
494
- file_name="products_backup.json",
495
- mime="application/json"
 
496
  )
497
 
498
  return st.session_state.products
 
 
499
 
500
  return []
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  def adjust_weights(calc):
503
  """Create weight adjustment interface."""
504
  st.subheader("3. Adjust Category Weights")
@@ -535,116 +300,9 @@ def adjust_weights(calc):
535
  })
536
  st.dataframe(weight_df, use_container_width=True)
537
 
538
- # Add aggregation method selection
539
- st.write("**Aggregation Method:**")
540
- aggregation_method = st.radio(
541
- "Choose how to combine category scores:",
542
- options=['weighted_sum', 'geometric_mean', 'threshold_penalty'],
543
- format_func=lambda x: {
544
- 'weighted_sum': 'Weighted Sum (No Penalty)',
545
- 'geometric_mean': 'Geometric Mean (Penalty for Poor Performance)',
546
- 'threshold_penalty': 'Threshold/Objective Penalty System'
547
- }[x],
548
- help="Weighted Sum: Full compensation between criteria. Geometric Mean: Penalizes poor performance. Threshold/Objective: Three-zone penalty system with elimination below thresholds."
549
- )
550
-
551
- # Update calculator's aggregation method
552
- calc.set_aggregation_method(aggregation_method)
553
-
554
- # Add threshold/objective configuration for penalty system
555
- if aggregation_method == 'threshold_penalty':
556
- st.write("**Configure Thresholds and Objectives:**")
557
- st.info("πŸ“ Set minimum acceptable values (thresholds) and target values (objectives) for each category.")
558
-
559
- col1, col2 = st.columns(2)
560
-
561
- with col1:
562
- st.write("**Thresholds (Minimum Acceptable):**")
563
- thresholds = {}
564
- for cat in calc.categories:
565
- direction = "maximize" if calc.maximize[cat] else "minimize"
566
- thresholds[cat] = st.number_input(
567
- f"{cat.title()} threshold",
568
- value=50.0,
569
- step=1.0,
570
- help=f"Minimum acceptable value for {cat} ({direction}). Below this = elimination."
571
- )
572
-
573
- with col2:
574
- st.write("**Objectives (Target Values):**")
575
- objectives = {}
576
- for cat in calc.categories:
577
- direction = "maximize" if calc.maximize[cat] else "minimize"
578
- objectives[cat] = st.number_input(
579
- f"{cat.title()} objective",
580
- value=80.0,
581
- step=1.0,
582
- help=f"Target value for {cat} ({direction}). At/above this = full score."
583
- )
584
-
585
- # Validate and apply threshold/objective configuration
586
- try:
587
- calc.set_thresholds(thresholds)
588
- calc.set_objectives(objectives)
589
-
590
- # Validate configuration
591
- validation_errors = calc.validate_penalty_configuration()
592
- if validation_errors:
593
- st.error("❌ Configuration Issues:")
594
- for error in validation_errors:
595
- st.error(f"β€’ {error}")
596
- else:
597
- st.success("βœ… Threshold/Objective configuration is valid")
598
-
599
- # Show penalty zones explanation
600
- with st.expander("πŸ“– How the Penalty System Works"):
601
- st.write("""
602
- **Three-Zone System for each category:**
603
-
604
- πŸ”΄ **Zone 1 - Elimination**: Below threshold β†’ Score = 0
605
- - Products failing to meet minimum requirements are heavily penalized
606
-
607
- 🟑 **Zone 2 - Penalty**: Between threshold and objective β†’ Linear scale (0-50)
608
- - Graduated penalty that decreases as you approach the objective
609
-
610
- 🟒 **Zone 3 - Full Reward**: At/above objective β†’ Full normalized score (50-100)
611
- - Products meeting targets compete on standard normalization
612
-
613
- **Example**: Reliability (maximize, threshold=80, objective=95)
614
- - Score 70: Gets 0 (below threshold)
615
- - Score 87: Gets ~23 (between threshold/objective)
616
- - Score 98: Gets ~90 (above objective, normalized against other qualified products)
617
- """)
618
-
619
- except Exception as e:
620
- st.error(f"❌ Error configuring penalty system: {str(e)}")
621
-
622
- # Update calculator's aggregation method
623
- calc.set_aggregation_method(aggregation_method)
624
-
625
  def display_results(calc):
626
  """Display analysis results."""
627
  st.subheader("πŸ“Š Results")
628
-
629
- # Display current aggregation method
630
- method_names = {
631
- 'weighted_sum': 'Weighted Sum (No Penalty)',
632
- 'geometric_mean': 'Geometric Mean (Penalty for Poor Performance)',
633
- 'threshold_penalty': 'Threshold/Objective Penalty System'
634
- }
635
- method_name = method_names.get(calc.aggregation_method, calc.aggregation_method)
636
- st.info(f"πŸ”§ Using: **{method_name}**")
637
-
638
- # Show penalty configuration if threshold penalty is active
639
- if calc.aggregation_method == 'threshold_penalty' and calc.use_penalties:
640
- with st.expander("🎯 Current Threshold/Objective Settings"):
641
- penalty_config = pd.DataFrame({
642
- 'Category': calc.categories,
643
- 'Direction': ['Maximize' if calc.maximize[cat] else 'Minimize' for cat in calc.categories],
644
- 'Threshold': [calc.thresholds[cat] for cat in calc.categories],
645
- 'Objective': [calc.objectives[cat] for cat in calc.categories]
646
- })
647
- st.dataframe(penalty_config, use_container_width=True)
648
 
649
  # Get results
650
  rankings = calc.rank_products()
@@ -656,10 +314,10 @@ def display_results(calc):
656
  with tab1:
657
  st.write("**Product Rankings:**")
658
 
659
- # Fix: Create medals list with correct length
660
  num_products = len(rankings)
661
  medals = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"] + [""] * max(0, num_products - 3)
662
- medals = medals[:num_products] # Trim to exact length needed
663
 
664
  ranking_df = pd.DataFrame({
665
  'Rank': range(1, num_products + 1),
@@ -703,7 +361,7 @@ def display_results(calc):
703
  fig_radar = go.Figure()
704
 
705
  normalized = calc.normalize_scores()
706
- for product in top_products[:3]: # Limit to top 3 for clarity
707
  values = [normalized[product][cat] for cat in calc.categories]
708
  fig_radar.add_trace(go.Scatterpolar(
709
  r=values,
@@ -721,81 +379,10 @@ def display_results(calc):
721
  showlegend=True,
722
  title="Normalized Scores by Category"
723
  )
724
-
725
- # Penalty zone visualization for threshold_penalty method
726
- if calc.aggregation_method == 'threshold_penalty' and calc.use_penalties:
727
- st.write("**Penalty Zone Analysis:**")
728
-
729
- # Create penalty zone visualization
730
- penalty_fig = go.Figure()
731
-
732
- for i, cat in enumerate(calc.categories):
733
- threshold = calc.thresholds[cat]
734
- objective = calc.objectives[cat]
735
-
736
- # Get all product scores for this category
737
- product_scores = [(name, calc.products[name][cat]) for name in calc.products]
738
- product_scores.sort(key=lambda x: x[1])
739
-
740
- # Create traces for penalty zones
741
- y_pos = [i] * len(product_scores)
742
- scores = [score for _, score in product_scores]
743
- names = [name for name, _ in product_scores]
744
-
745
- # Zone colors based on scores
746
- colors = []
747
- for _, score in product_scores:
748
- if calc.maximize[cat]:
749
- if score < threshold:
750
- colors.append('red') # Below threshold
751
- elif score < objective:
752
- colors.append('orange') # Between threshold and objective
753
- else:
754
- colors.append('green') # Above objective
755
- else:
756
- if score > threshold:
757
- colors.append('red') # Above threshold (bad for minimize)
758
- elif score > objective:
759
- colors.append('orange') # Between objective and threshold
760
- else:
761
- colors.append('green') # Below objective (good for minimize)
762
-
763
- # Add scatter points for products
764
- penalty_fig.add_trace(go.Scatter(
765
- x=scores,
766
- y=y_pos,
767
- mode='markers',
768
- marker=dict(size=12, color=colors),
769
- text=names,
770
- name=f'{cat} scores',
771
- showlegend=False
772
- ))
773
-
774
- # Add threshold and objective lines
775
- penalty_fig.add_vline(x=threshold, line=dict(color='red', dash='dash'),
776
- annotation_text=f'{cat} threshold')
777
- penalty_fig.add_vline(x=objective, line=dict(color='green', dash='dash'),
778
- annotation_text=f'{cat} objective')
779
-
780
- penalty_fig.update_layout(
781
- title="Product Scores vs Thresholds/Objectives",
782
- xaxis_title="Score Value",
783
- yaxis=dict(
784
- tickmode='array',
785
- tickvals=list(range(len(calc.categories))),
786
- ticktext=calc.categories
787
- ),
788
- height=max(300, len(calc.categories) * 60)
789
- )
790
-
791
- # Legend explanation
792
- st.write("πŸ”΄ Red: Below threshold (eliminated) | 🟠 Orange: Between threshold/objective (penalized) | 🟒 Green: Above objective (full score)")
793
-
794
- # MOVE THIS LINE INSIDE THE CONDITIONAL BLOCK
795
- st.plotly_chart(penalty_fig, use_container_width=True)
796
-
797
- # ADD THIS LINE FOR THE RADAR CHART (outside the penalty block)
798
  st.plotly_chart(fig_radar, use_container_width=True)
 
 
799
 
800
  if __name__ == "__main__":
801
  main()
 
15
  st.title("βš–οΈ Multi-Criteria Decision Analysis (MCDA) Calculator")
16
  st.markdown("Compare products and alternatives using weighted criteria analysis")
17
 
18
+ # Add info about Excel functionality
19
+ with st.expander("πŸ“– About This Tool"):
20
+ st.write("""
21
+ **What is MCDA?**
22
+ Multi-Criteria Decision Analysis helps you make objective decisions when comparing
23
+ products, services, or alternatives across multiple criteria.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ **How to use:**
26
+ 1. Define your evaluation categories (Performance, Cost, Quality, etc.)
27
+ 2. Add the products/options you want to compare
28
+ 3. Adjust category weights based on importance
29
+ 4. View rankings and detailed analysis
30
 
31
+ **Need Excel functionality?**
32
+ Download our companion Excel template generator for offline analysis!
33
+ """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ # Manual interface only for HF Spaces
36
+ manual_interface()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  def manual_interface():
39
  """Handle manual data entry interface."""
40
+ st.header("✏️ Interactive Data Entry")
41
 
42
  # Step 1: Define categories
43
  st.subheader("1. Define Categories")
 
49
  "Enter categories (one per line):",
50
  value="Performance\nCost\nReliability",
51
  height=100,
52
+ help="Enter each category on a new line. Examples: Performance, Cost, Quality, Ease_of_Use"
53
  )
54
  categories = [cat.strip() for cat in categories_input.split('\n') if cat.strip()]
55
 
 
125
  if 'products' not in st.session_state:
126
  st.session_state.products = []
127
 
128
+ # Quick add section
129
+ st.write("**Quick Add Products:**")
130
+ with st.form("quick_add_product", clear_on_submit=True):
131
+ cols = st.columns([2] + [1] * len(categories) + [1])
132
+
133
+ with cols[0]:
134
+ product_name = st.text_input("Product Name", placeholder="e.g., Product A")
135
+
136
+ scores = {}
137
+ for i, cat in enumerate(categories, 1):
138
+ with cols[i]:
139
+ scores[cat] = st.number_input(f"{cat}", value=0.0, step=1.0, key=f"quick_{cat}")
140
+
141
+ with cols[-1]:
142
+ st.write(" ") # Spacer
143
+ submitted = st.form_submit_button("βž• Add", type="primary")
144
+
145
+ if submitted and product_name:
146
+ # Check if product name already exists
147
+ existing_names = [p['name'] for p in st.session_state.products]
148
+ if product_name in existing_names:
149
+ st.error(f"❌ Product '{product_name}' already exists. Please use a different name.")
150
+ else:
151
+ new_product = {'name': product_name, **scores}
152
+ st.session_state.products.append(new_product)
153
+ st.success(f"βœ… Added {product_name}")
154
+ st.rerun()
155
 
156
  # Display current products with edit/delete options
157
  if st.session_state.products:
 
165
  edited_df = st.data_editor(
166
  df,
167
  use_container_width=True,
168
+ num_rows="dynamic",
169
+ key="products_editor",
170
+ column_config={
171
+ "name": st.column_config.TextColumn("Product Name", help="Name of the product/option"),
172
+ **{cat: st.column_config.NumberColumn(cat, help=f"Score for {cat}") for cat in categories}
173
+ }
174
  )
175
 
176
  # Update session state with edited data
177
  st.session_state.products = edited_df.to_dict('records')
178
 
179
+ # Bulk operations
180
+ col1, col2, col3 = st.columns(3)
 
 
181
 
182
  with col1:
183
+ if st.button("πŸ—‘οΈ Clear All Products", type="secondary"):
184
+ st.session_state.products = []
185
+ if 'edit_mode' in st.session_state:
 
 
 
 
 
 
 
 
 
 
186
  st.session_state.edit_mode = {}
187
+ st.success("βœ… Cleared all products")
188
  st.rerun()
189
 
190
+ with col2:
191
+ # Export current products to JSON for backup
192
+ import json
193
+ products_json = json.dumps(st.session_state.products, indent=2)
194
+ st.download_button(
195
+ label="πŸ“₯ Export Data (JSON)",
196
+ data=products_json,
197
+ file_name="mcda_data_backup.json",
198
+ mime="application/json",
199
+ help="Download your data for backup or sharing"
200
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
+ with col3:
203
+ # Generate Excel template based on current setup
204
+ if st.button("πŸ“Š Generate Excel Template"):
205
+ excel_buffer = create_excel_template(categories, st.session_state.products)
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  st.download_button(
207
+ label="πŸ“ Download Excel Template",
208
+ data=excel_buffer,
209
+ file_name=f"MCDA_Template_{len(categories)}categories.xlsx",
210
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
211
+ help="Download Excel file with your current setup"
212
  )
213
 
214
  return st.session_state.products
215
+ else:
216
+ st.info("πŸ‘† Add your first product above to get started")
217
 
218
  return []
219
 
220
+ def create_excel_template(categories, products_data):
221
+ """Create Excel template with current data."""
222
+ import io
223
+
224
+ # Create Config sheet
225
+ config_df = pd.DataFrame({
226
+ 'category': categories,
227
+ 'maximize': [True if cat.lower() != 'cost' else False for cat in categories]
228
+ })
229
+
230
+ # Create Data sheet with current products or templates
231
+ if products_data:
232
+ data_df = pd.DataFrame(products_data)
233
+ else:
234
+ # Create template with placeholder data
235
+ template_data = []
236
+ for i in range(3):
237
+ row = {'name': f'Product_{chr(65+i)}'}
238
+ for cat in categories:
239
+ row[cat] = 0.0
240
+ template_data.append(row)
241
+ data_df = pd.DataFrame(template_data)
242
+
243
+ # Create Excel file in memory
244
+ excel_buffer = io.BytesIO()
245
+ with pd.ExcelWriter(excel_buffer, engine='openpyxl') as writer:
246
+ config_df.to_excel(writer, sheet_name='Config', index=False)
247
+ data_df.to_excel(writer, sheet_name='Data', index=False)
248
+
249
+ # Add instructions sheet
250
+ instructions = pd.DataFrame([
251
+ ["MCDA Excel Template", ""],
252
+ ["", ""],
253
+ ["Config Sheet:", "Defines categories and optimization direction"],
254
+ ["Data Sheet:", "Contains your product data"],
255
+ ["", ""],
256
+ ["To use:", ""],
257
+ ["1. Modify data in Data sheet", ""],
258
+ ["2. Adjust Config sheet if needed", ""],
259
+ ["3. Use with desktop MCDA tools", ""],
260
+ ], columns=['Item', 'Description'])
261
+ instructions.to_excel(writer, sheet_name='Instructions', index=False)
262
+
263
+ excel_buffer.seek(0)
264
+ return excel_buffer.getvalue()
265
+
266
+ # Include all your existing functions: adjust_weights, display_results, etc.
267
  def adjust_weights(calc):
268
  """Create weight adjustment interface."""
269
  st.subheader("3. Adjust Category Weights")
 
300
  })
301
  st.dataframe(weight_df, use_container_width=True)
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  def display_results(calc):
304
  """Display analysis results."""
305
  st.subheader("πŸ“Š Results")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
  # Get results
308
  rankings = calc.rank_products()
 
314
  with tab1:
315
  st.write("**Product Rankings:**")
316
 
317
+ # Create medals list with correct length
318
  num_products = len(rankings)
319
  medals = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"] + [""] * max(0, num_products - 3)
320
+ medals = medals[:num_products]
321
 
322
  ranking_df = pd.DataFrame({
323
  'Rank': range(1, num_products + 1),
 
361
  fig_radar = go.Figure()
362
 
363
  normalized = calc.normalize_scores()
364
+ for product in top_products[:3]:
365
  values = [normalized[product][cat] for cat in calc.categories]
366
  fig_radar.add_trace(go.Scatterpolar(
367
  r=values,
 
379
  showlegend=True,
380
  title="Normalized Scores by Category"
381
  )
382
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  st.plotly_chart(fig_radar, use_container_width=True)
384
+ else:
385
+ st.info("Add more products to see visualizations")
386
 
387
  if __name__ == "__main__":
388
  main()