NavyDevilDoc commited on
Commit
0953e72
Β·
verified Β·
1 Parent(s): 7e6495e

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +315 -135
src/streamlit_app.py CHANGED
@@ -15,29 +15,12 @@ def 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,7 +32,7 @@ def manual_interface():
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,33 +108,33 @@ def data_entry_interface(categories):
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,105 +148,124 @@ def data_entry_interface(categories):
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,9 +302,116 @@ def adjust_weights(calc):
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,10 +423,10 @@ def display_results(calc):
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,7 +470,7 @@ def display_results(calc):
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,10 +488,81 @@ def display_results(calc):
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()
 
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
  def manual_interface():
22
  """Handle manual data entry interface."""
23
+ st.header("✏️ Manual Data Entry")
24
 
25
  # Step 1: Define categories
26
  st.subheader("1. Define Categories")
 
32
  "Enter categories (one per line):",
33
  value="Performance\nCost\nReliability",
34
  height=100,
35
+ help="Enter each category on a new line"
36
  )
37
  categories = [cat.strip() for cat in categories_input.split('\n') if cat.strip()]
38
 
 
108
  if 'products' not in st.session_state:
109
  st.session_state.products = []
110
 
111
+ # Add new product form
112
+ with st.expander("βž• Add New Product", expanded=len(st.session_state.products) == 0):
113
+ with st.form("add_product"):
114
+ col1, col2 = st.columns([1, 2])
115
+
116
+ with col1:
117
+ product_name = st.text_input("Product Name", placeholder="e.g., Product A")
118
+
119
+ with col2:
120
+ scores = {}
121
+ cols = st.columns(len(categories))
122
+ for i, cat in enumerate(categories):
123
+ with cols[i]:
124
+ scores[cat] = st.number_input(f"{cat}", value=0.0, step=1.0)
125
+
126
+ submitted = st.form_submit_button("Add Product")
127
+
128
+ if submitted and product_name:
129
+ # Check if product name already exists
130
+ existing_names = [p['name'] for p in st.session_state.products]
131
+ if product_name in existing_names:
132
+ st.error(f"❌ Product '{product_name}' already exists. Please use a different name.")
133
+ else:
134
+ new_product = {'name': product_name, **scores}
135
+ st.session_state.products.append(new_product)
136
+ st.success(f"βœ… Added {product_name}")
137
+ st.rerun()
138
 
139
  # Display current products with edit/delete options
140
  if st.session_state.products:
 
148
  edited_df = st.data_editor(
149
  df,
150
  use_container_width=True,
151
+ num_rows="dynamic", # This should allow adding/deleting rows
152
+ key="products_editor"
 
 
 
 
153
  )
154
 
155
  # Update session state with edited data
156
  st.session_state.products = edited_df.to_dict('records')
157
 
158
+ # Individual product management
159
+ st.write("**Manage Individual Products:**")
160
+
161
+ col1, col2, col3 = st.columns([2, 1, 1])
162
 
163
  with col1:
164
+ # Select product to edit/delete
165
+ if st.session_state.products:
166
+ product_names = [p['name'] for p in st.session_state.products]
167
+ selected_product = st.selectbox(
168
+ "Select product to manage:",
169
+ options=product_names,
170
+ key="product_selector"
171
+ )
172
 
173
  with col2:
174
+ # Edit button
175
+ if st.button("✏️ Edit Selected", key="edit_button"):
176
+ if 'edit_mode' not in st.session_state:
177
+ st.session_state.edit_mode = {}
178
+ st.session_state.edit_mode[selected_product] = True
179
+ st.rerun()
 
 
 
 
180
 
181
  with col3:
182
+ # Delete button
183
+ if st.button("πŸ—‘οΈ Delete Selected", key="delete_button", type="secondary"):
184
+ st.session_state.products = [p for p in st.session_state.products if p['name'] != selected_product]
185
+ st.success(f"βœ… Deleted {selected_product}")
186
+ st.rerun()
187
+
188
+ # Edit mode for selected product
189
+ if 'edit_mode' in st.session_state and selected_product in st.session_state.edit_mode:
190
+ if st.session_state.edit_mode[selected_product]:
191
+
192
+ st.write(f"**Editing: {selected_product}**")
193
+
194
+ # Find the product to edit
195
+ product_to_edit = next(p for p in st.session_state.products if p['name'] == selected_product)
196
+
197
+ with st.form(f"edit_product_{selected_product}"):
198
+ col1, col2 = st.columns([1, 2])
199
+
200
+ with col1:
201
+ new_name = st.text_input("Product Name", value=selected_product)
202
+
203
+ with col2:
204
+ new_scores = {}
205
+ cols = st.columns(len(categories))
206
+ for i, cat in enumerate(categories):
207
+ with cols[i]:
208
+ current_value = product_to_edit.get(cat, 0.0)
209
+ new_scores[cat] = st.number_input(
210
+ f"{cat}",
211
+ value=float(current_value),
212
+ step=1.0,
213
+ key=f"edit_{cat}_{selected_product}"
214
+ )
215
+
216
+ col_save, col_cancel = st.columns(2)
217
+
218
+ with col_save:
219
+ save_changes = st.form_submit_button("πŸ’Ύ Save Changes", type="primary")
220
+
221
+ with col_cancel:
222
+ cancel_edit = st.form_submit_button("❌ Cancel")
223
+
224
+ if save_changes:
225
+ # Update the product
226
+ for i, product in enumerate(st.session_state.products):
227
+ if product['name'] == selected_product:
228
+ st.session_state.products[i] = {'name': new_name, **new_scores}
229
+ break
230
+
231
+ # Clear edit mode
232
+ st.session_state.edit_mode[selected_product] = False
233
+ st.success(f"βœ… Updated product: {new_name}")
234
+ st.rerun()
235
+
236
+ if cancel_edit:
237
+ # Clear edit mode
238
+ st.session_state.edit_mode[selected_product] = False
239
+ st.rerun()
240
+
241
+ # Bulk operations
242
+ if len(st.session_state.products) > 0:
243
+ st.write("**Bulk Operations:**")
244
+ col1, col2 = st.columns(2)
245
+
246
+ with col1:
247
+ if st.button("πŸ—‘οΈ Clear All Products", type="secondary"):
248
+ st.session_state.products = []
249
+ if 'edit_mode' in st.session_state:
250
+ st.session_state.edit_mode = {}
251
+ st.success("βœ… Cleared all products")
252
+ st.rerun()
253
+
254
+ with col2:
255
+ # Export current products to JSON for backup
256
+ import json
257
+ products_json = json.dumps(st.session_state.products, indent=2)
258
  st.download_button(
259
+ label="πŸ“₯ Export Products (JSON)",
260
+ data=products_json,
261
+ file_name="products_backup.json",
262
+ mime="application/json"
 
263
  )
264
 
265
  return st.session_state.products
 
 
266
 
267
  return []
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  def adjust_weights(calc):
270
  """Create weight adjustment interface."""
271
  st.subheader("3. Adjust Category Weights")
 
302
  })
303
  st.dataframe(weight_df, use_container_width=True)
304
 
305
+ # Add aggregation method selection
306
+ st.write("**Aggregation Method:**")
307
+ aggregation_method = st.radio(
308
+ "Choose how to combine category scores:",
309
+ options=['weighted_sum', 'geometric_mean', 'threshold_penalty'],
310
+ format_func=lambda x: {
311
+ 'weighted_sum': 'Weighted Sum (No Penalty)',
312
+ 'geometric_mean': 'Geometric Mean (Penalty for Poor Performance)',
313
+ 'threshold_penalty': 'Threshold/Objective Penalty System'
314
+ }[x],
315
+ help="Weighted Sum: Full compensation between criteria. Geometric Mean: Penalizes poor performance. Threshold/Objective: Three-zone penalty system with elimination below thresholds."
316
+ )
317
+
318
+ # Update calculator's aggregation method
319
+ calc.set_aggregation_method(aggregation_method)
320
+
321
+ # Add threshold/objective configuration for penalty system
322
+ if aggregation_method == 'threshold_penalty':
323
+ st.write("**Configure Thresholds and Objectives:**")
324
+ st.info("πŸ“ Set minimum acceptable values (thresholds) and target values (objectives) for each category.")
325
+
326
+ col1, col2 = st.columns(2)
327
+
328
+ with col1:
329
+ st.write("**Thresholds (Minimum Acceptable):**")
330
+ thresholds = {}
331
+ for cat in calc.categories:
332
+ direction = "maximize" if calc.maximize[cat] else "minimize"
333
+ thresholds[cat] = st.number_input(
334
+ f"{cat.title()} threshold",
335
+ value=50.0,
336
+ step=1.0,
337
+ help=f"Minimum acceptable value for {cat} ({direction}). Below this = elimination."
338
+ )
339
+
340
+ with col2:
341
+ st.write("**Objectives (Target Values):**")
342
+ objectives = {}
343
+ for cat in calc.categories:
344
+ direction = "maximize" if calc.maximize[cat] else "minimize"
345
+ objectives[cat] = st.number_input(
346
+ f"{cat.title()} objective",
347
+ value=80.0,
348
+ step=1.0,
349
+ help=f"Target value for {cat} ({direction}). At/above this = full score."
350
+ )
351
+
352
+ # Validate and apply threshold/objective configuration
353
+ try:
354
+ calc.set_thresholds(thresholds)
355
+ calc.set_objectives(objectives)
356
+
357
+ # Validate configuration
358
+ validation_errors = calc.validate_penalty_configuration()
359
+ if validation_errors:
360
+ st.error("❌ Configuration Issues:")
361
+ for error in validation_errors:
362
+ st.error(f"β€’ {error}")
363
+ else:
364
+ st.success("βœ… Threshold/Objective configuration is valid")
365
+
366
+ # Show penalty zones explanation
367
+ with st.expander("πŸ“– How the Penalty System Works"):
368
+ st.write("""
369
+ **Three-Zone System for each category:**
370
+
371
+ πŸ”΄ **Zone 1 - Elimination**: Below threshold β†’ Score = 0
372
+ - Products failing to meet minimum requirements are heavily penalized
373
+
374
+ 🟑 **Zone 2 - Penalty**: Between threshold and objective β†’ Linear scale (0-50)
375
+ - Graduated penalty that decreases as you approach the objective
376
+
377
+ 🟒 **Zone 3 - Full Reward**: At/above objective β†’ Full normalized score (50-100)
378
+ - Products meeting targets compete on standard normalization
379
+
380
+ **Example**: Reliability (maximize, threshold=80, objective=95)
381
+ - Score 70: Gets 0 (below threshold)
382
+ - Score 87: Gets ~23 (between threshold/objective)
383
+ - Score 98: Gets ~90 (above objective, normalized against other qualified products)
384
+ """)
385
+
386
+ except Exception as e:
387
+ st.error(f"❌ Error configuring penalty system: {str(e)}")
388
+
389
+ # Update calculator's aggregation method
390
+ calc.set_aggregation_method(aggregation_method)
391
+
392
  def display_results(calc):
393
  """Display analysis results."""
394
  st.subheader("πŸ“Š Results")
395
+
396
+ # Display current aggregation method
397
+ method_names = {
398
+ 'weighted_sum': 'Weighted Sum (No Penalty)',
399
+ 'geometric_mean': 'Geometric Mean (Penalty for Poor Performance)',
400
+ 'threshold_penalty': 'Threshold/Objective Penalty System'
401
+ }
402
+ method_name = method_names.get(calc.aggregation_method, calc.aggregation_method)
403
+ st.info(f"πŸ”§ Using: **{method_name}**")
404
+
405
+ # Show penalty configuration if threshold penalty is active
406
+ if calc.aggregation_method == 'threshold_penalty' and calc.use_penalties:
407
+ with st.expander("🎯 Current Threshold/Objective Settings"):
408
+ penalty_config = pd.DataFrame({
409
+ 'Category': calc.categories,
410
+ 'Direction': ['Maximize' if calc.maximize[cat] else 'Minimize' for cat in calc.categories],
411
+ 'Threshold': [calc.thresholds[cat] for cat in calc.categories],
412
+ 'Objective': [calc.objectives[cat] for cat in calc.categories]
413
+ })
414
+ st.dataframe(penalty_config, use_container_width=True)
415
 
416
  # Get results
417
  rankings = calc.rank_products()
 
423
  with tab1:
424
  st.write("**Product Rankings:**")
425
 
426
+ # Fix: Create medals list with correct length
427
  num_products = len(rankings)
428
  medals = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"] + [""] * max(0, num_products - 3)
429
+ medals = medals[:num_products] # Trim to exact length needed
430
 
431
  ranking_df = pd.DataFrame({
432
  'Rank': range(1, num_products + 1),
 
470
  fig_radar = go.Figure()
471
 
472
  normalized = calc.normalize_scores()
473
+ for product in top_products[:3]: # Limit to top 3 for clarity
474
  values = [normalized[product][cat] for cat in calc.categories]
475
  fig_radar.add_trace(go.Scatterpolar(
476
  r=values,
 
488
  showlegend=True,
489
  title="Normalized Scores by Category"
490
  )
491
+
492
+ # Penalty zone visualization for threshold_penalty method
493
+ if calc.aggregation_method == 'threshold_penalty' and calc.use_penalties:
494
+ st.write("**Penalty Zone Analysis:**")
495
+
496
+ # Create penalty zone visualization
497
+ penalty_fig = go.Figure()
498
+
499
+ for i, cat in enumerate(calc.categories):
500
+ threshold = calc.thresholds[cat]
501
+ objective = calc.objectives[cat]
502
+
503
+ # Get all product scores for this category
504
+ product_scores = [(name, calc.products[name][cat]) for name in calc.products]
505
+ product_scores.sort(key=lambda x: x[1])
506
+
507
+ # Create traces for penalty zones
508
+ y_pos = [i] * len(product_scores)
509
+ scores = [score for _, score in product_scores]
510
+ names = [name for name, _ in product_scores]
511
+
512
+ # Zone colors based on scores
513
+ colors = []
514
+ for _, score in product_scores:
515
+ if calc.maximize[cat]:
516
+ if score < threshold:
517
+ colors.append('red') # Below threshold
518
+ elif score < objective:
519
+ colors.append('orange') # Between threshold and objective
520
+ else:
521
+ colors.append('green') # Above objective
522
+ else:
523
+ if score > threshold:
524
+ colors.append('red') # Above threshold (bad for minimize)
525
+ elif score > objective:
526
+ colors.append('orange') # Between objective and threshold
527
+ else:
528
+ colors.append('green') # Below objective (good for minimize)
529
+
530
+ # Add scatter points for products
531
+ penalty_fig.add_trace(go.Scatter(
532
+ x=scores,
533
+ y=y_pos,
534
+ mode='markers',
535
+ marker=dict(size=12, color=colors),
536
+ text=names,
537
+ name=f'{cat} scores',
538
+ showlegend=False
539
+ ))
540
+
541
+ # Add threshold and objective lines
542
+ penalty_fig.add_vline(x=threshold, line=dict(color='red', dash='dash'),
543
+ annotation_text=f'{cat} threshold')
544
+ penalty_fig.add_vline(x=objective, line=dict(color='green', dash='dash'),
545
+ annotation_text=f'{cat} objective')
546
+
547
+ penalty_fig.update_layout(
548
+ title="Product Scores vs Thresholds/Objectives",
549
+ xaxis_title="Score Value",
550
+ yaxis=dict(
551
+ tickmode='array',
552
+ tickvals=list(range(len(calc.categories))),
553
+ ticktext=calc.categories
554
+ ),
555
+ height=max(300, len(calc.categories) * 60)
556
+ )
557
+
558
+ # Legend explanation
559
+ st.write("πŸ”΄ Red: Below threshold (eliminated) | 🟠 Orange: Between threshold/objective (penalized) | 🟒 Green: Above objective (full score)")
560
+
561
+ # MOVE THIS LINE INSIDE THE CONDITIONAL BLOCK
562
+ st.plotly_chart(penalty_fig, use_container_width=True)
563
+
564
+ # ADD THIS LINE FOR THE RADAR CHART (outside the penalty block)
565
  st.plotly_chart(fig_radar, use_container_width=True)
 
 
566
 
567
  if __name__ == "__main__":
568
  main()