https://github.com/keyurbhojak1992/diamond-matcher.git

#1
by bhojakkeyur - opened
.streamlit.config.toml DELETED
@@ -1,3 +0,0 @@
1
- # .streamlit/config.toml
2
- [browser]
3
- gatherUsageStats = false
 
 
 
 
Streamlit Stone Pairing App.py DELETED
@@ -1,817 +0,0 @@
1
- import os
2
- # Redirect Streamlit config/metrics to writable location
3
- os.environ['STREAMLIT_HOME'] = '/tmp/.streamlit'
4
- os.environ['XDG_CONFIG_HOME'] = '/tmp/.config'
5
- os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
6
-
7
-
8
- import streamlit as st
9
- import pandas as pd
10
- from itertools import combinations
11
- import io
12
- from pandas.api.types import CategoricalDtype
13
- # xlsxwriter is needed for advanced Excel formatting
14
- import xlsxwriter
15
-
16
- # --- 2. Define Criteria Mappings and Tolerances ---
17
- # These are moved inside process_df to keep them scoped,
18
- # but defined outside functions that use them to avoid re-creation on every call.
19
-
20
- # Carat threshold constant (for overall above/below 0.90 categories)
21
- CARAT_THRESHOLD = 0.90
22
-
23
- # NEW: Define Carat Sizing Groups based on your specifications
24
- # These define strict bins for matching
25
- CARAT_SIZING_GROUPS = {
26
- 'below_0.90': [
27
- (0.30, 0.39),
28
- (0.40, 0.49),
29
- (0.50, 0.69),
30
- (0.70, 0.89)
31
- ],
32
- 'above_0.90': [
33
- (0.90, 0.99),
34
- (1.00, 1.49),
35
- (1.50, 1.99),
36
- (2.00, 2.99),
37
- (3.00, 3.99),
38
- (4.00, 4.99),
39
- (5.00, float('inf')) # Added for carats 5.00 and above, as not explicitly listed but necessary
40
- ]
41
- }
42
-
43
- # UPDATED: Carat-dependent numeric tolerances as per your request
44
- carat_tolerances = {
45
- 'below_0.90': {
46
- 'Ratio': 0.08,
47
- 'LENGH': 0.20,
48
- 'WIDTH': 0.20,
49
- 'HEIGHT': 0.25,
50
- },
51
- 'above_0.90': {
52
- 'Ratio': 0.10,
53
- 'LENGH': 0.22,
54
- 'WIDTH': 0.22,
55
- 'HEIGHT': 0.25,
56
- }
57
- }
58
-
59
- # Color order for sorting and matching
60
- color_order = ['D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N']
61
- color_map = {color: i for i, color in enumerate(color_order)}
62
- color_cat_type = CategoricalDtype(categories=color_order, ordered=True)
63
-
64
- # Clarity order for sorting and numerical comparison
65
- clarity_sort_order = ['FL', 'IF', 'VVS1', 'VVS2', 'VS1', 'VS2', 'SI1', 'SI2']
66
- clarity_cat_type = CategoricalDtype(categories=clarity_sort_order, ordered=True)
67
- clarity_value_map = {grade: i for i, grade in enumerate(clarity_sort_order)} # Lower index = higher clarity
68
-
69
- # REPLACED: Carat-dependent Clarity group mappings with Ranks (as per your provided snippet)
70
- carat_clarity_groups_map_ranked = {
71
- 'below_0.90': {
72
- 'rank1': {
73
- ('FL', 'IF'), ('IF', 'FL'), # FL - IF
74
- ('VVS1', 'VVS1'), ('VVS1', 'VVS2'), ('VVS2', 'VVS1'), ('VVS2', 'VVS2'), # VVS - VVS
75
- ('VS1', 'VS1'), ('VS1', 'VS2'), ('VS2', 'VS1'), ('VS2', 'VS2'), # VS - VS
76
- ('SI1', 'SI1'), ('SI1', 'SI2'), ('SI2', 'SI1'), ('SI2', 'SI2') # SI - SI
77
- },
78
- 'rank2_groups': { # These are the previous group definitions
79
- 'FL': {'C1'}, 'IF': {'C1'}, 'VVS1': {'C1', 'C2'}, # FL to VVS2, VVS1 to VS2
80
- 'VVS2': {'C1', 'C2'}, # FL to VVS2, VVS1 - VS2 -- C3 removed as per discussion
81
- 'VS1': {'C1', 'C2', 'C3'}, # VVS1 to VS2, VS1 to SI1
82
- 'VS2': {'C2', 'C3', 'C4'}, # VVS1 - VS2, VS1 to SI1, VS2 to SI2
83
- 'SI1': {'C3', 'C4'}, # VS1-SI1, VS2-SI2, VS1 to SI1, VS2 to SI2
84
- 'SI2': {'C4'} # VS2-SI2
85
- }
86
- },
87
- 'above_0.90': { # Assuming same ranking rules for above 0.90
88
- 'rank1': {
89
- ('FL', 'IF'), ('IF', 'FL'),
90
- ('VVS1', 'VVS1'), ('VVS1', 'VVS2'), ('VVS2', 'VVS1'), ('VVS2', 'VVS2'),
91
- ('VS1', 'VS1'), ('VS1', 'VS2'), ('VS2', 'VS1'), ('VS2', 'VS2'),
92
- ('SI1', 'SI1'), ('SI1', 'SI2'), ('SI2', 'SI1'), ('SI2', 'SI2')
93
- },
94
- 'rank2_groups': {
95
- 'FL': {'C1'}, 'IF': {'C1'}, 'VVS1': {'C1', 'C2'}, # FL to VVS2, VVS1 to VS2
96
- 'VVS2': {'C1', 'C2'}, # FL to VVS2, VVS1 - VS2 -- C3 removed as per discussion
97
- 'VS1': {'C1', 'C2', 'C3'}, # VVS1 to VS2, VS1 to SI1
98
- 'VS2': {'C2', 'C3', 'C4'}, # VVS1 - VS2, VS1 to SI1, VS2 to SI2
99
- 'SI1': {'C3', 'C4'}, # VS1-SI1, VS2-SI2, VS1 to SI1, VS2 to SI2
100
- 'SI2': {'C4'} # VS2-SI2
101
- }
102
- }
103
- }
104
-
105
- # Lab 100% Match
106
- allowed_labs = {'NONE', 'IGI', 'GIA', 'HRD'}
107
-
108
- # Carat-dependent Flour with Lab-Based Grouping
109
- carat_flour_lab_based_groups = {
110
- 'below_0.90': {
111
- 'NONE': {
112
- 'NON': {'LC_G1'}, 'VSL': {'LC_G1'}, 'SL': {'LC_G1'}, 'FNT': {'LC_G1', 'LC_G2'}, 'MED': {'LC_G1', 'LC_G2', 'LC_G3'}, 'STG': {'LC_G2', 'LC_G3'}, 'VST': {'LC_G3'}
113
- },
114
- 'IGI': {
115
- 'NON': {'G1'}, 'VSL': {'G1', 'G2'}, 'SL': {'G2', 'G3'}, 'MED': {'G3', 'G4'}, 'STG': {'G4'}
116
- },
117
- 'GIA': {
118
- 'NON': {'LC_G1'}, 'VSL': {'LC_G1'}, 'SL': {'LC_G1'}, 'FNT': {'LC_G1', 'LC_G2'}, 'MED': {'LC_G1', 'LC_G2', 'LC_G3'}, 'STG': {'LC_G2', 'LC_G3'}, 'VST': {'LC_G3'}
119
- },
120
- 'HRD': {
121
- 'NON': {'G1'}, 'VSL': {'G1'}, 'SL': {'G1', 'G2'}, 'MED': {'G2', 'G3'}, 'STG': {'G3'}
122
- },
123
- 'NONE_GIA_COMBINED': { # Rule for NONE and GIA combination
124
- 'NON': {'LC_G1'}, 'VSL': {'LC_G1'}, 'SL': {'LC_G1'}, 'FNT': {'LC_G1', 'LC_G2'}, 'MED': {'LC_G1', 'LC_G2', 'LC_G3'}, 'STG': {'LC_G2', 'LC_G3'}, 'VST': {'LC_G3'}
125
- }
126
- },
127
- 'above_0.90': {
128
- 'NONE': {
129
- 'NON': {'HC_G1'}, 'VSL': {'HC_G1'}, 'SL': {'HC_G1'}, 'FNT': {'HC_G1', 'HC_G2'}, 'MED': {'HC_G1', 'HC_G2', 'HC_G3'}, 'STG': {'HC_G2', 'HC_G3'}, 'VST': {'HC_G3'}
130
- },
131
- 'IGI': {
132
- 'NON': {'G1'}, 'VSL': {'G1', 'G2'}, 'SL': {'G2', 'G3'}, 'MED': {'G3', 'G4'}, 'STG': {'G4'}
133
- },
134
- 'GIA': {
135
- 'NON': {'HC_G1'}, 'VSL': {'HC_G1'}, 'SL': {'HC_G1'}, 'FNT': {'HC_G1', 'HC_G2'}, 'MED': {'HC_G1', 'HC_G2', 'HC_G3'}, 'STG': {'HC_G2', 'HC_G3'}, 'VST': {'HC_G3'}
136
- },
137
- 'HRD': {
138
- 'NON': {'G1'}, 'VSL': {'G1'}, 'SL': {'G1', 'G2'}, 'MED': {'G2', 'G3'}, 'STG': {'G3'}
139
- },
140
- 'NONE_GIA_COMBINED': {
141
- 'NON': {'HC_G1'}, 'VSL': {'HC_G1'}, 'SL': {'HC_G1'}, 'FNT': {'HC_G1', 'HC_G2'}, 'MED': {'HC_G1', 'HC_G2', 'HC_G3'}, 'STG': {'HC_G2', 'HC_G3'}, 'VST': {'HC_G3'}
142
- }
143
- }
144
- }
145
-
146
- # Shade Order
147
- shade_order = ['NONE', 'INSPECTION ADVISABLE', 'MIX TINGE 1', 'MIX TINGE 2', 'MIX TINGE 3', 'LIGHT BROWN', 'BROWN', 'STRONG BROWN', 'VERY STRONG BROWN']
148
- shade_cat_type = CategoricalDtype(categories=shade_order, ordered=True)
149
-
150
- # NEW: Carat-dependent Shade Grouping with Ranks (similar to clarity)
151
- carat_shade_groups_map_ranked = {
152
- 'below_0.90': {
153
- 'rank1': {
154
- ('NONE', 'NONE'),
155
- ('INSPECTION ADVISABLE', 'INSPECTION ADVISABLE'),
156
- ('MIX TINGE 1', 'MIX TINGE 2'), ('MIX TINGE 2', 'MIX TINGE 1'),
157
- ('MIX TINGE 2', 'MIX TINGE 3'), ('MIX TINGE 3', 'MIX TINGE 2'),
158
- ('LIGHT BROWN', 'BROWN'), ('BROWN', 'LIGHT BROWN'),
159
- ('BROWN', 'STRONG BROWN'), ('STRONG BROWN', 'BROWN'),
160
- ('STRONG BROWN', 'VERY STRONG BROWN'), ('VERY STRONG BROWN', 'STRONG BROWN')
161
- },
162
- 'rank2_groups': {
163
- 'NONE': {'SG1'},
164
- 'INSPECTION ADVISABLE': {'SG1'},
165
- 'MIX TINGE 1': {'SG1', 'SG2'},
166
- 'MIX TINGE 2': {'SG2', 'SG3'},
167
- 'MIX TINGE 3': {'SG3'},
168
- 'LIGHT BROWN': {'SG1', 'SG2'},
169
- 'BROWN': {'SG2', 'SG3'},
170
- 'STRONG BROWN': {'SG3'},
171
- 'VERY STRONG BROWN': {'SG3'}
172
- }
173
- },
174
- 'above_0.90': {
175
- 'rank1': { # Same rules for above 0.90 as per user request
176
- ('NONE', 'NONE'),
177
- ('INSPECTION ADVISABLE', 'INSPECTION ADVISABLE'),
178
- ('MIX TINGE 1', 'MIX TINGE 2'), ('MIX TINGE 2', 'MIX TINGE 1'),
179
- ('MIX TINGE 2', 'MIX TINGE 3'), ('MIX TINGE 3', 'MIX TINGE 2'),
180
- ('LIGHT BROWN', 'BROWN'), ('BROWN', 'LIGHT BROWN'),
181
- ('BROWN', 'STRONG BROWN'), ('STRONG BROWN', 'BROWN'),
182
- ('STRONG BROWN', 'VERY STRONG BROWN'), ('VERY STRONG BROWN', 'STRONG BROWN')
183
- },
184
- 'rank2_groups': { # Same rules for above 0.90 as per user request
185
- 'NONE': {'SG1'},
186
- 'INSPECTION ADVISABLE': {'SG1'},
187
- 'MIX TINGE 1': {'SG1', 'SG2'},
188
- 'MIX TINGE 2': {'SG2', 'SG3'},
189
- 'MIX TINGE 3': {'SG3'},
190
- 'LIGHT BROWN': {'SG1', 'SG2'},
191
- 'BROWN': {'SG2', 'SG3'},
192
- 'STRONG BROWN': {'SG3'},
193
- 'VERY STRONG BROWN': {'SG3'}
194
- }
195
- }
196
- }
197
-
198
-
199
- # --- 3. Helper Functions for Matching Logic ---
200
- def is_numeric_match(val1, val2, tolerance, round_decimals=6):
201
- if pd.isna(val1) or pd.isna(val2):
202
- return False
203
- calculated_diff = abs(val1 - val2)
204
- return round(calculated_diff, round_decimals) <= tolerance
205
-
206
- def is_color_match(color1, color2, color_map):
207
- if pd.isna(color1) or pd.isna(color2) or color1 not in color_map or color2 not in color_map: return False
208
- idx1 = color_map[color1]
209
- idx2 = color_map[color2]
210
- return abs(idx1 - idx2) <= 1
211
-
212
- def get_carat_sizing_group(carat_value, carat_sizing_groups, carat_threshold):
213
- if pd.isna(carat_value):
214
- return None
215
-
216
- broad_range_key = 'below_0.90' if carat_value < carat_threshold else 'above_0.90'
217
- specific_sizing_groups = carat_sizing_groups.get(broad_range_key, [])
218
-
219
- for lower, upper in specific_sizing_groups:
220
- if lower <= carat_value <= upper:
221
- return (lower, upper)
222
- return None
223
-
224
- def is_clarity_group_match(clarity1, clarity2, carat_value, carat_clarity_groups_map_ranked, carat_threshold):
225
- if pd.isna(clarity1) or pd.isna(clarity2): return None
226
-
227
- carat_range_key = 'below_0.90' if carat_value < carat_threshold else 'above_0.90'
228
- clarity_rules_for_carat = carat_clarity_groups_map_ranked.get(carat_range_key, {})
229
-
230
- rank1_set = clarity_rules_for_carat.get('rank1', set())
231
- if (clarity1, clarity2) in rank1_set or (clarity2, clarity1) in rank1_set:
232
- return 1
233
-
234
- rank2_groups_map = clarity_rules_for_carat.get('rank2_groups', {})
235
- groups1 = rank2_groups_map.get(clarity1, set())
236
- groups2 = rank2_groups_map.get(clarity2, set())
237
-
238
- if bool(groups1.intersection(groups2)):
239
- return 2
240
- return None
241
-
242
- def is_lab_match(lab1, lab2, allowed_labs):
243
- if pd.isna(lab1) or pd.isna(lab2):
244
- return False
245
-
246
- lab1_upper = str(lab1).upper() # Ensure string and upper case for comparison
247
- lab2_upper = str(lab2).upper()
248
-
249
- if lab1_upper not in allowed_labs or lab2_upper not in allowed_labs:
250
- return False
251
-
252
- if lab1_upper == lab2_upper:
253
- return True
254
- elif (lab1_upper == 'NONE' and lab2_upper == 'GIA') or (lab1_upper == 'GIA' and lab2_upper == 'NONE'):
255
- return True
256
- return False
257
-
258
- def is_flour_lab_based_group_match(flour1, flour2, lab1, lab2, carat_value, carat_flour_lab_based_groups, allowed_labs, carat_threshold):
259
- if pd.isna(flour1) or pd.isna(flour2):
260
- return False
261
- if pd.isna(lab1) or pd.isna(lab2):
262
- return False
263
-
264
- lab1_upper = str(lab1).upper()
265
- lab2_upper = str(lab2).upper()
266
-
267
- if not is_lab_match(lab1_upper, lab2_upper, allowed_labs):
268
- return False
269
-
270
- effective_lab_key = None
271
- if lab1_upper == lab2_upper:
272
- effective_lab_key = lab1_upper
273
- elif (lab1_upper == 'NONE' and lab2_upper == 'GIA') or (lab1_upper == 'GIA' and lab2_upper == 'NONE'):
274
- effective_lab_key = 'NONE_GIA_COMBINED'
275
-
276
- if not effective_lab_key:
277
- return False
278
-
279
- carat_range_key = 'below_0.90' if carat_value < carat_threshold else 'above_0.90'
280
-
281
- flour_lab_map = carat_flour_lab_based_groups.get(carat_range_key, {}).get(effective_lab_key, {})
282
-
283
- groups1 = flour_lab_map.get(flour1, set())
284
- groups2 = flour_lab_map.get(flour2, set())
285
-
286
- return bool(groups1.intersection(groups2))
287
-
288
- def is_shade_group_match(shade1, shade2, carat_value, carat_shade_groups_map_ranked, carat_threshold):
289
- if pd.isna(shade1) or pd.isna(shade2): return None
290
-
291
- carat_range_key = 'below_0.90' if carat_value < carat_threshold else 'above_0.90'
292
- shade_rules_for_carat = carat_shade_groups_map_ranked.get(carat_range_key, {})
293
-
294
- rank1_set = shade_rules_for_carat.get('rank1', set())
295
- if (shade1, shade2) in rank1_set or (shade2, shade1) in rank1_set:
296
- return 1
297
-
298
- rank2_groups_map = shade_rules_for_carat.get('rank2_groups', {})
299
- groups1 = rank2_groups_map.get(shade1, set())
300
- groups2 = rank2_groups_map.get(shade2, set())
301
-
302
- if bool(groups1.intersection(groups2)):
303
- return 2
304
- return None
305
-
306
- # --- Main Processing Function ---
307
- def process_df(df: pd.DataFrame):
308
- # Ensure numeric columns are actually numeric
309
- # Define numeric_cols inside the function or pass it as an argument
310
- numeric_cols = ['Carat', 'Depth', 'Table', 'Ratio', 'LENGH', 'WIDTH', 'HEIGHT', 'Dis%', 'Price/Ct$', 'Amount$', 'Diameter']
311
- for col in numeric_cols:
312
- df[col] = pd.to_numeric(df[col], errors='coerce')
313
-
314
- # --- 4. Main Matching Logic (Detailed for Reporting - generating all potential pairs) ---
315
- all_potential_matched_pairs_raw = []
316
- all_comparisons_for_report = [] # To store detailed comparison results for text reports
317
-
318
- # Using st.progress for visual feedback
319
- progress_text = "Analyzing stone pairs..."
320
- my_bar = st.progress(0, text=progress_text)
321
- total_combinations = len(list(combinations(df.index, 2))) # Calculate once
322
-
323
- for idx_count, (i, j) in enumerate(combinations(df.index, 2)):
324
- stone1 = df.loc[i]
325
- stone2 = df.loc[j]
326
-
327
- # Ensure Stone ID_1 is always numerically smaller for consistency in raw pairs
328
- if int(stone1['Stone ID']) > int(stone2['Stone ID']):
329
- stone1, stone2 = stone2, stone1
330
-
331
- reasons_for_match = []
332
- reasons_for_no_match = []
333
- current_pair_is_match = True # Assume match until a criterion fails
334
- clarity_match_rank = None
335
- shade_match_rank = None
336
-
337
- abs_diff_clarity_index = float('inf')
338
- clarity_bias_index = float('inf')
339
-
340
- # Determine broad carat range based on Stone 1's carat for applying rules
341
- pair_carat_for_rules = stone1['Carat']
342
- broad_carat_range_key = 'below_0.90' if pair_carat_for_rules < CARAT_THRESHOLD else 'above_0.90'
343
-
344
- # Get the appropriate tolerances for the current broad carat range
345
- current_numeric_tolerances = carat_tolerances[broad_carat_range_key]
346
-
347
- # Carat Sizing Group Match (mandatory first check)
348
- carat_group1 = get_carat_sizing_group(stone1['Carat'], CARAT_SIZING_GROUPS, CARAT_THRESHOLD)
349
- carat_group2 = get_carat_sizing_group(stone2['Carat'], CARAT_SIZING_GROUPS, CARAT_THRESHOLD)
350
-
351
- carat_sizing_group_current_match = (carat_group1 is not None and carat_group1 == carat_group2)
352
-
353
- if carat_sizing_group_current_match:
354
- reasons_for_match.append(f"Carat Sizing Group: Stone 1 ({stone1['Carat']:.2f}ct) and Stone 2 ({stone2['Carat']:.2f}ct) are both in group {carat_group1}.")
355
- else:
356
- current_pair_is_match = False
357
- if carat_group1 is None or carat_group2 is None:
358
- reasons_for_no_match.append(f"Carat Sizing Group mismatch: One or both stones ({stone1['Carat']:.2f}ct, {stone2['Carat']:.2f}ct) outside defined sizing groups.")
359
- else:
360
- reasons_for_no_match.append(f"Carat Sizing Group mismatch: Stone 1 ({stone1['Carat']:.2f}ct) is in group {carat_group1}, Stone 2 ({stone2['Carat']:.2f}ct) is in group {carat_group2}.")
361
-
362
- if current_pair_is_match:
363
- # Lab Match
364
- lab_current_match = is_lab_match(stone1['Lab'], stone2['Lab'], allowed_labs)
365
- if lab_current_match:
366
- reasons_for_match.append(f"Lab: '{stone1['Lab']}' and '{stone2['Lab']}' matched based on specific rules.")
367
- else:
368
- current_pair_is_match = False
369
- reason_lab_detail = []
370
- if pd.isna(stone1['Lab']) or pd.isna(stone2['Lab']):
371
- reason_lab_detail.append(f"Missing Lab value for one or both stones.")
372
- else:
373
- s1_lab_upper = str(stone1['Lab']).upper()
374
- s2_lab_upper = str(stone2['Lab']).upper()
375
- if s1_lab_upper not in allowed_labs or s2_lab_upper not in allowed_labs:
376
- reason_lab_detail.append(f"One or both labs ('{stone1['Lab']}', '{stone2['Lab']}') are not in allowed labs: {allowed_labs}.")
377
- elif (s1_lab_upper == 'NONE' and s2_lab_upper == 'GIA') or \
378
- (s1_lab_upper == 'GIA' and s2_lab_upper == 'NONE'):
379
- reason_lab_detail.append(f"Labs ('{stone1['Lab']}', '{stone2['Lab']}') should have matched by NONE/GIA combination rule but didn't. Check values.")
380
- else:
381
- reason_lab_detail.append(f"No specific rule for labs ('{stone1['Lab']}', '{stone2['Lab']}') to match.")
382
- reasons_for_no_match.append(f"Lab mismatch: {' '.join(reason_lab_detail)}")
383
-
384
- # Shape Match
385
- if current_pair_is_match:
386
- shape_current_match = (stone1['Shape'] == stone2['Shape'])
387
- if shape_current_match:
388
- reasons_for_match.append(f"Shape: '{stone1['Shape']}' matched.")
389
- else:
390
- current_pair_is_match = False
391
- reasons_for_no_match.append(f"Shape mismatch: Stone 1 is '{stone1['Shape']}', Stone 2 is '{stone2['Shape']}'.")
392
-
393
- # Color Match
394
- if current_pair_is_match:
395
- color_current_match = is_color_match(stone1['Color'], stone2['Color'], color_map)
396
- if color_current_match:
397
- reasons_for_match.append(f"Color: '{stone1['Color']}' and '{stone2['Color']}' matched (within 1 grade).")
398
- else:
399
- current_pair_is_match = False
400
- reasons_for_no_match.append(f"Color mismatch: Stone 1 is '{stone1['Color']}', Stone 2 is '{stone2['Color']}' (exceeds 1 grade tolerance or missing/invalid).")
401
-
402
- # Clarity Match
403
- if current_pair_is_match:
404
- clarity_match_rank = is_clarity_group_match(stone1['Clarity'], stone2['Clarity'], pair_carat_for_rules, carat_clarity_groups_map_ranked, CARAT_THRESHOLD)
405
- if clarity_match_rank is not None:
406
- reasons_for_match.append(f"Clarity: '{stone1['Clarity']}' and '{stone2['Clarity']}' matched with Rank {clarity_match_rank} for {broad_carat_range_key} carat range.")
407
- if stone1['Clarity'] in clarity_value_map and stone2['Clarity'] in clarity_value_map:
408
- val1 = clarity_value_map[stone1['Clarity']]
409
- val2 = clarity_value_map[stone2['Clarity']]
410
- abs_diff_clarity_index = abs(val1 - val2)
411
- clarity_bias_index = val2 - val1
412
- else:
413
- current_pair_is_match = False
414
- reasons_for_no_match.append(f"Clarity mismatch: Stone 1 is '{stone1['Clarity']}', Stone 2 is '{stone2['Clarity']}' (no common group or invalid for {broad_carat_range_key} carat range).")
415
-
416
- # Flour Match (Lab-based)
417
- if current_pair_is_match:
418
- flour_current_match = is_flour_lab_based_group_match(
419
- stone1['Flour'], stone2['Flour'], stone1['Lab'], stone2['Lab'],
420
- pair_carat_for_rules, carat_flour_lab_based_groups, allowed_labs, CARAT_THRESHOLD
421
- )
422
- if flour_current_match:
423
- reasons_for_match.append(f"Flour: '{stone1['Flour']}' and '{stone2['Flour']}' matched based on Lab '{stone1['Lab']}' and '{stone2['Lab']}' group rules for {broad_carat_range_key} carat range.")
424
- else:
425
- current_pair_is_match = False
426
- reason_flour_detail = []
427
- if pd.isna(stone1['Flour']) or pd.isna(stone2['Flour']):
428
- reason_flour_detail.append(f"Flour value missing for one or both stones ('{stone1['Flour']}' vs '{stone2['Flour']}').")
429
- elif not is_lab_match(stone1['Lab'], stone2['Lab'], allowed_labs):
430
- reason_flour_detail.append(f"Labs mismatch or not allowed for Flour comparison ('{stone1['Lab']}' vs '{stone2['Lab']}').")
431
- else:
432
- effective_lab_key = None
433
- lab1_upper = str(stone1['Lab']).upper() if pd.notna(stone1['Lab']) else ''
434
- lab2_upper = str(stone2['Lab']).upper() if pd.notna(stone2['Lab']) else ''
435
-
436
- if lab1_upper == lab2_upper:
437
- effective_lab_key = lab1_upper
438
- elif (lab1_upper == 'NONE' and lab2_upper == 'GIA') or (lab1_upper == 'GIA' and lab2_upper == 'NONE'):
439
- effective_lab_key = 'NONE_GIA_COMBINED'
440
-
441
- if effective_lab_key and broad_carat_range_key in carat_flour_lab_based_groups and effective_lab_key in carat_flour_lab_based_groups[broad_carat_range_key]:
442
- flour_lab_map = carat_flour_lab_based_groups[broad_carat_range_key][effective_lab_key]
443
- groups1 = flour_lab_map.get(stone1['Flour'], set())
444
- groups2 = flour_lab_map.get(stone2['Flour'], set())
445
- if not bool(groups1.intersection(groups2)):
446
- reason_flour_detail.append(f"Flour mismatch: Stone 1 ('{stone1['Flour']}') and Stone 2 ('{stone2['Flour']}') have no common group for the effective Lab '{effective_lab_key}' and {broad_carat_range_key} carat range.")
447
- else:
448
- reason_flour_detail.append(f"Flour comparison failed due to an unexpected scenario (e.g., internal logic error or unhandled lab/flour combination).")
449
- reasons_for_no_match.append(f"Flour mismatch: {' '.join(reason_flour_detail)}")
450
-
451
- # Shade Match
452
- if current_pair_is_match:
453
- shade_match_rank = is_shade_group_match(stone1['Shade'], stone2['Shade'], pair_carat_for_rules, carat_shade_groups_map_ranked, CARAT_THRESHOLD)
454
- if shade_match_rank is not None:
455
- reasons_for_match.append(f"Shade: '{stone1['Shade']}' and '{stone2['Shade']}' matched with Rank {shade_match_rank} for {broad_carat_range_key} carat range.")
456
- else:
457
- current_pair_is_match = False
458
- reasons_for_no_match.append(f"Shade mismatch: Stone 1 is '{stone1['Shade']}', Stone 2 is '{stone2['Shade']}' (no common group or invalid for {broad_carat_range_key} carat range).")
459
-
460
- # Numeric Match
461
- if current_pair_is_match:
462
- numeric_mismatches_details = []
463
- for col, tol in current_numeric_tolerances.items():
464
- if not is_numeric_match(stone1[col], stone2[col], tol):
465
- numeric_mismatches_details.append(
466
- f" - {col}: Stone 1={stone1[col]:.2f}, Stone 2={stone2[col]:.2f}, diff={abs(stone1[col] - stone2[col]):.2f}, tolerance={tol:.2f}. Diff exceeded tolerance for {broad_carat_range_key} carat range."
467
- )
468
- all_numeric_current_match = not bool(numeric_mismatches_details)
469
-
470
- if all_numeric_current_match:
471
- reasons_for_match.append(f"All numeric criteria matched within tolerance for {broad_carat_range_key} carat range.")
472
- else:
473
- current_pair_is_match = False
474
- reasons_for_no_match.append("Numeric criteria mismatches:")
475
- reasons_for_no_match.extend(numeric_mismatches_details)
476
-
477
- # Final decision for the pair for reporting and Excel data collection
478
- if current_pair_is_match:
479
- is_100_percent_sub_match = (str(stone1['Color']) == str(stone2['Color'])) and \
480
- (str(stone1['Clarity']) == str(stone2['Clarity'])) and \
481
- (str(stone1['Flour']) == str(stone2['Flour']))
482
-
483
- all_comparisons_for_report.append({
484
- 'stone_id_1': stone1['Stone ID'],
485
- 'stone_id_2': stone2['Stone ID'],
486
- 'is_match': True,
487
- 'reasons': reasons_for_match
488
- })
489
-
490
- all_potential_matched_pairs_raw.append({
491
- 'stone1_data': stone1.to_dict(),
492
- 'stone2_data': stone2.to_dict(),
493
- '_Highlight_Row': is_100_percent_sub_match,
494
- '_Clarity_Match_Rank': clarity_match_rank,
495
- '_Abs_Diff_Clarity_Index': abs_diff_clarity_index,
496
- '_Clarity_Bias_Index': clarity_bias_index,
497
- '_Shade_Match_Rank': shade_match_rank
498
- })
499
- else:
500
- all_comparisons_for_report.append({
501
- 'stone_id_1': stone1['Stone ID'],
502
- 'stone_id_2': stone2['Stone ID'],
503
- 'is_match': False,
504
- 'reasons': reasons_for_no_match
505
- })
506
-
507
- # Update progress bar
508
- my_bar.progress((idx_count + 1) / total_combinations, text=progress_text)
509
-
510
- # --- Calculate Global Stone ID Counts across all potential matched pairs ---
511
- global_stone_id_counts = {}
512
- for pair in all_potential_matched_pairs_raw:
513
- s1_id = pair['stone1_data']['Stone ID']
514
- s2_id = pair['stone2_data']['Stone ID']
515
- global_stone_id_counts[s1_id] = global_stone_id_counts.get(s1_id, 0) + 1
516
- global_stone_id_counts[s2_id] = global_stone_id_counts.get(s2_id, 0) + 1
517
-
518
- # --- Apply NEW Global Filtering Logic based on sorted pair priority and uniqueness ---
519
- final_filtered_matched_pairs = []
520
- claimed_stones = set()
521
-
522
- # Augment each potential pair with sorting metrics
523
- for pair in all_potential_matched_pairs_raw:
524
- s1_id = pair['stone1_data']['Stone ID']
525
- s2_id = pair['stone2_data']['Stone ID']
526
-
527
- pair_sum_global_counts = global_stone_id_counts.get(s1_id, 0) + global_stone_id_counts.get(s2_id, 0)
528
- pair_min_id = min(int(s1_id), int(s2_id))
529
- pair_max_id = max(int(s1_id), int(s2_id))
530
-
531
- pair['_Pair_Sort_Key'] = (
532
- pair['_Clarity_Match_Rank'],
533
- pair['_Shade_Match_Rank'],
534
- (pair['_Abs_Diff_Clarity_Index'], -pair['_Clarity_Bias_Index']),
535
- not pair['_Highlight_Row'],
536
- pair_min_id,
537
- pair_max_id,
538
- pair_sum_global_counts
539
- )
540
-
541
- sorted_all_potential_matched_pairs = sorted(all_potential_matched_pairs_raw, key=lambda p: p['_Pair_Sort_Key'])
542
-
543
- for pair in sorted_all_potential_matched_pairs:
544
- s1_id = pair['stone1_data']['Stone ID']
545
- s2_id = pair['stone2_data']['Stone ID']
546
-
547
- if s1_id not in claimed_stones and s2_id not in claimed_stones:
548
- final_filtered_matched_pairs.append(pair)
549
- claimed_stones.add(s1_id)
550
- claimed_stones.add(s2_id)
551
-
552
-
553
- # --- 5. Prepare Output DataFrame for Excel ---
554
- wide_output_rows = []
555
- for pair_data in final_filtered_matched_pairs:
556
- stone1_raw = pair_data['stone1_data']
557
- stone2_raw = pair_data['stone2_data']
558
- is_highlighted = pair_data['_Highlight_Row']
559
-
560
- if int(stone1_raw['Stone ID']) > int(stone2_raw['Stone ID']):
561
- stone1_raw, stone2_raw = stone2_raw, stone1_raw
562
-
563
- stone1_full_data = pd.Series(stone1_raw)
564
- stone2_full_data = pd.Series(stone2_raw)
565
-
566
- current_pair_row = {}
567
- for col in df.columns:
568
- current_pair_row[f'{col}_1'] = stone1_full_data[col]
569
- current_pair_row[f'{col}_2'] = stone2_full_data[col]
570
-
571
- current_pair_row['Pair No'] = 0 # Placeholder, will be assigned later
572
- current_pair_row['_Highlight_Row'] = is_highlighted
573
- wide_output_rows.append(current_pair_row)
574
-
575
- excel_output_data = io.BytesIO()
576
- matched_report_content = io.StringIO()
577
- non_matched_report_content = io.StringIO()
578
-
579
- if wide_output_rows:
580
- final_wide_df = pd.DataFrame(wide_output_rows)
581
-
582
- # Convert CategoricalDtype for sorting
583
- final_wide_df['Color_1'] = final_wide_df['Color_1'].fillna('').astype(color_cat_type)
584
- final_wide_df['Color_2'] = final_wide_df['Color_2'].fillna('').astype(color_cat_type)
585
- final_wide_df['Clarity_1'] = final_wide_df['Clarity_1'].fillna('').astype(clarity_cat_type)
586
- final_wide_df['Clarity_2'] = final_wide_df['Clarity_2'].fillna('').astype(clarity_cat_type)
587
- final_wide_df['Shade_1'] = final_wide_df['Shade_1'].fillna('').astype(shade_cat_type)
588
- final_wide_df['Shade_2'] = final_wide_df['Shade_2'].fillna('').astype(shade_cat_type)
589
-
590
- final_wide_df['Stone ID_1_Num'] = pd.to_numeric(final_wide_df['Stone ID_1'], errors='coerce')
591
-
592
- final_wide_df = final_wide_df.sort_values(
593
- by=['Stone ID_1_Num', 'Color_1', 'Clarity_1', 'Color_2', 'Clarity_2'],
594
- ascending=True
595
- ).reset_index(drop=True)
596
-
597
- final_wide_df = final_wide_df.drop(columns=['Stone ID_1_Num'])
598
-
599
- final_wide_df['Pair No'] = final_wide_df.groupby('Stone ID_1').ngroup() + 1
600
- final_wide_df['_Is_Last_In_Pair_Group'] = final_wide_df['Stone ID_1'].shift(-1) != final_wide_df['Stone ID_1']
601
- final_wide_df.loc[len(final_wide_df) - 1, '_Is_Last_In_Pair_Group'] = True
602
-
603
- preferred_column_order_str = "Pair No\tStone ID_1\tStone ID_2\tShape_1\tShape_2\tCarat_1\tCarat_2\tLab_1\tLab_2\tColor_1\tColor_2\tClarity_1\tClarity_2\tFlour_1\tFlour_2\tRatio_1\tRatio_2\tLENGH_1\tLENGH_2\tWIDTH_1\tWIDTH_2\tHEIGHT_1\tHEIGHT_2\tTable_1\tTable_2\tDepth_1\tDepth_2\tShade_1\tShade_2\tMilky_1\tMilky_2"
604
- preferred_cols_list = preferred_column_order_str.split('\t')
605
-
606
- current_cols_before_dropping_highlight = final_wide_df.columns.tolist()
607
- final_ordered_cols = []
608
- for col in preferred_cols_list:
609
- if col in current_cols_before_dropping_highlight:
610
- final_ordered_cols.append(col)
611
-
612
- remaining_cols = [col for col in current_cols_before_dropping_highlight
613
- if col not in final_ordered_cols and col not in ['_Highlight_Row', '_Is_Last_In_Pair_Group']]
614
- final_ordered_cols.extend(remaining_cols)
615
-
616
- final_wide_df = final_wide_df[final_ordered_cols + ['_Highlight_Row', '_Is_Last_In_Pair_Group']]
617
-
618
- # --- 6. Save to XLSX with Advanced Formatting ---
619
- # Using BytesIO to save to memory
620
- writer = pd.ExcelWriter(excel_output_data, engine='xlsxwriter')
621
-
622
- final_wide_df_for_excel = final_wide_df.drop(columns=['_Highlight_Row', '_Is_Last_In_Pair_Group'])
623
- final_wide_df_for_excel.to_excel(writer, sheet_name='Matched Pairs', index=False)
624
-
625
- workbook = writer.book
626
- worksheet = writer.sheets['Matched Pairs']
627
-
628
- worksheet.freeze_panes(1, 0)
629
-
630
- header_format = workbook.add_format({
631
- 'bold': True, 'bg_color': '#F2F2F2', 'border': 1,
632
- 'border_color': '#C0C0C0', 'align': 'center', 'valign': 'vcenter'
633
- })
634
- data_format = workbook.add_format({
635
- 'border': 1, 'border_color': '#C0C0C0', 'align': 'center', 'valign': 'vcenter'
636
- })
637
- numeric_data_format = workbook.add_format({
638
- 'num_format': '0.00', 'border': 1, 'border_color': '#C0C0C0',
639
- 'align': 'center', 'valign': 'vcenter'
640
- })
641
- highlight_row_format = workbook.add_format({
642
- 'bg_color': '#E0FFD4', 'border': 1, 'border_color': '#C0C0C0',
643
- 'align': 'center', 'valign': 'vcenter'
644
- })
645
- highlight_numeric_format = workbook.add_format({
646
- 'num_format': '0.00', 'bg_color': '#E0FFD4', 'border': 1, 'border_color': '#C0C0C0',
647
- 'align': 'center', 'valign': 'vcenter'
648
- })
649
-
650
- data_format_dark_border = workbook.add_format({
651
- 'border': 1, 'border_color': '#C0C0C0', 'bottom': 5, 'bottom_color': '#000000',
652
- 'align': 'center', 'valign': 'vcenter'
653
- })
654
- numeric_data_format_dark_border = workbook.add_format({
655
- 'num_format': '0.00', 'border': 1, 'border_color': '#C0C0C0', 'bottom': 5, 'bottom_color': '#000000',
656
- 'align': 'center', 'valign': 'vcenter'
657
- })
658
- highlight_row_format_dark_border = workbook.add_format({
659
- 'bg_color': '#E0FFD4', 'border': 1, 'border_color': '#C0C0C0', 'bottom': 5, 'bottom_color': '#000000',
660
- 'align': 'center', 'valign': 'vcenter'
661
- })
662
- highlight_numeric_format_dark_border = workbook.add_format({
663
- 'num_format': '0.00', 'bg_color': '#E0FFD4', 'border': 1, 'border_color': '#C0C0C0', 'bottom': 5, 'bottom_color': '#000000',
664
- 'align': 'center', 'valign': 'vcenter'
665
- })
666
-
667
- numeric_cols_suffixes = [f'{col}_1' for col in numeric_cols] + [f'{col}_2' for col in numeric_cols]
668
-
669
- for col_num, value in enumerate(final_wide_df_for_excel.columns.values):
670
- worksheet.write(0, col_num, value, header_format)
671
-
672
- for row_num in range(len(final_wide_df)):
673
- is_row_highlighted = final_wide_df.loc[row_num, '_Highlight_Row']
674
- is_last_in_group = final_wide_df.loc[row_num, '_Is_Last_In_Pair_Group']
675
-
676
- for col_num, col_name in enumerate(final_wide_df_for_excel.columns):
677
- cell_value = final_wide_df_for_excel.iloc[row_num, col_num]
678
- excel_row = row_num + 1
679
-
680
- current_cell_format = None
681
- if col_name in numeric_cols_suffixes:
682
- if is_row_highlighted and is_last_in_group:
683
- current_cell_format = highlight_numeric_format_dark_border
684
- elif is_row_highlighted:
685
- current_cell_format = highlight_numeric_format
686
- elif is_last_in_group:
687
- current_cell_format = numeric_data_format_dark_border
688
- else:
689
- current_cell_format = numeric_data_format
690
- else:
691
- if is_row_highlighted and is_last_in_group:
692
- current_cell_format = highlight_row_format_dark_border
693
- elif is_row_highlighted:
694
- current_cell_format = highlight_row_format
695
- elif is_last_in_group:
696
- current_cell_format = data_format_dark_border
697
- else:
698
- current_cell_format = data_format
699
-
700
- if pd.isna(cell_value):
701
- worksheet.write(excel_row, col_num, '', current_cell_format)
702
- elif col_name in ['DETAIL_1', 'DETAIL_2']:
703
- # Assuming DETAIL_1/DETAIL_2 contain URLs if you want them as clickable links
704
- # If they contain other text, handle accordingly
705
- if isinstance(cell_value, str) and cell_value.startswith('http'):
706
- worksheet.write_url(excel_row, col_num, cell_value, current_cell_format, "DETAILS")
707
- else:
708
- worksheet.write(excel_row, col_num, str(cell_value), current_cell_format) # Convert to string for non-URL text
709
- else:
710
- worksheet.write(excel_row, col_num, cell_value, current_cell_format)
711
-
712
- for i, col in enumerate(final_wide_df_for_excel.columns):
713
- max_len = max(
714
- final_wide_df_for_excel[col].astype(str).apply(len).max(),
715
- len(col)
716
- )
717
- if col in ['DETAIL_1', 'DETAIL_2']:
718
- max_len = max(max_len, len("DETAILS"))
719
- worksheet.set_column(i, i, max_len + 2)
720
-
721
- writer.close()
722
- else:
723
- st.warning("No matching pairs found for Excel report.")
724
-
725
- # --- 7. Generate Text Reports ---
726
- matched_comparisons = [c for c in all_comparisons_for_report if c['is_match']]
727
- non_matched_comparisons = [c for c in all_comparisons_for_report if not c['is_match']]
728
-
729
- matched_stones_count = len(matched_comparisons)
730
- non_matched_stones_count = len(non_matched_comparisons)
731
-
732
- matched_report_content.write(f"No_of_stones: {matched_stones_count}\n")
733
- matched_report_content.write("--- Matched Stone Pairs Detailed Report ---\n")
734
- if not matched_comparisons:
735
- matched_report_content.write("No matching pairs found.\n")
736
-
737
- for idx, comp in enumerate(matched_comparisons):
738
- matched_report_content.write(f"\n--- Pair {idx+1}: {comp['stone_id_1']} & {comp['stone_id_2']} ---\n")
739
- matched_report_content.write("Match Status: MATCHED\n")
740
- matched_report_content.write("Reasons for Match:\n")
741
- for reason in comp['reasons']:
742
- matched_report_content.write(f" - {reason}\n")
743
-
744
- non_matched_report_content.write(f"No_of_stones: {non_matched_stones_count}\n")
745
- non_matched_report_content.write("--- Non-Matched Stone Pairs Detailed Report ---\n")
746
- if not non_matched_comparisons:
747
- non_matched_report_content.write("No non-matching pairs found.\n")
748
-
749
- for idx, comp in enumerate(non_matched_comparisons):
750
- non_matched_report_content.write(f"\n--- Pair {idx+1}: {comp['stone_id_1']} & {comp['stone_id_2']} ---\n")
751
- non_matched_report_content.write("Match Status: NOT MATCHED\n")
752
- non_matched_report_content.write("Reasons for Not Matching:\n")
753
- for reason in comp['reasons']:
754
- non_matched_report_content.write(f" - {reason}\n")
755
-
756
- return excel_output_data.getvalue(), matched_report_content.getvalue(), non_matched_report_content.getvalue()
757
-
758
-
759
- st.set_page_config(page_title="Stone Pairing App", layout="centered")
760
-
761
- st.title("💎 Stone Pair Matching Application")
762
- st.markdown("""
763
- Upload your Excel file containing stone data, and this app will identify matching pairs
764
- based on predefined criteria, generating a formatted Excel output and detailed reports.
765
- """)
766
-
767
- uploaded_file = st.file_uploader("Upload your raw Excel file (.xlsx)", type=["xlsx"])
768
-
769
- if uploaded_file:
770
- st.info("File uploaded successfully! Starting processing...")
771
- try:
772
- df = pd.read_excel(uploaded_file)
773
- if df.empty:
774
- st.error("The uploaded Excel file is empty. Please upload a file with data.")
775
- else:
776
- # Display first few rows for user confirmation
777
- st.subheader("Preview of your data:")
778
- st.dataframe(df.head())
779
-
780
- st.write("Processing, this may take a while for large files...")
781
- with st.spinner("Processing complex matching logic..."):
782
- excel_data, matched_report_str, non_matched_report_str = process_df(df)
783
-
784
- st.success("Processing complete! You can now download the results.")
785
-
786
- col1, col2, col3 = st.columns(3)
787
-
788
- with col1:
789
- st.download_button(
790
- label="Download Processed Excel",
791
- data=excel_data,
792
- file_name="matched_stones_pairwise_comparison_formatted.xlsx",
793
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
794
- help="Download the Excel file with matched pairs, formatted with highlights and borders."
795
- )
796
- with col2:
797
- st.download_button(
798
- label="Download Matched Pairs Report",
799
- data=matched_report_str,
800
- file_name="matched_stones_detailed_report.txt",
801
- mime="text/plain",
802
- help="Get a text report detailing all matched stone pairs and reasons."
803
- )
804
- with col3:
805
- st.download_button(
806
- label="Download Non-Matched Pairs Report",
807
- data=non_matched_report_str,
808
- file_name="non_matched_stones_detailed_report.txt",
809
- mime="text/plain",
810
- help="Get a text report detailing all non-matched stone pairs and reasons."
811
- )
812
- except Exception as e:
813
- st.error(f"An error occurred during file processing: {e}")
814
- st.error("Please ensure your Excel file has the expected column names and data types as per the original script.")
815
-
816
- st.markdown("---")
817
- st.markdown("Developed with ❤️ for efficient stone pairing.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
- streamlit
2
  pandas
3
- xlsxwriter
4
- openpyxl
 
1
+ altair
2
  pandas
3
+ streamlit