AccruedInnovation commited on
Commit
d961a54
·
verified ·
1 Parent(s): 03be11b

Update code/app.py

Browse files

update for mobile responsiveness

Files changed (1) hide show
  1. code/app.py +848 -873
code/app.py CHANGED
@@ -1,873 +1,848 @@
1
- import panel as pn
2
- import pandas as pd
3
- import param
4
- from bokeh.models.formatters import PrintfTickFormatter
5
-
6
- # Initialize Panel extension for Tabulator and set a global sizing mode
7
- pn.extension(
8
- "tabulator",
9
- sizing_mode="stretch_width",
10
- template="fast",
11
- # theme = 'dark',
12
- )
13
-
14
- # --- Styling Placeholders (as per user instruction) ---
15
- slider_design = {}
16
- slider_style = {}
17
- slider_stylesheet = []
18
-
19
-
20
- # --- Helper for NumberFormatters ---
21
- def get_formatter(format_str):
22
- if format_str == "%i":
23
- return pn.widgets.tables.NumberFormatter(format="0")
24
- elif format_str == "%.1f":
25
- return pn.widgets.tables.NumberFormatter(format="0.0")
26
- elif format_str == "%.2f":
27
- return pn.widgets.tables.NumberFormatter(format="0.00")
28
- elif format_str == "%.4f":
29
- return pn.widgets.tables.NumberFormatter(format="0.0000")
30
- elif format_str == "$%.02f":
31
- return pn.widgets.tables.NumberFormatter(format="$0,0.00")
32
- return format_str
33
-
34
-
35
- class CannabinoidEstimator(param.Parameterized):
36
- # --- Input Parameters ---
37
- kg_processed_per_hour = param.Number(
38
- default=150.0,
39
- bounds=(0, 2000),
40
- step=1.0,
41
- label="Biomass processed per hour (kg)",
42
- )
43
- finished_product_yield_pct = param.Number(
44
- default=60.0,
45
- bounds=(0.01, 100),
46
- step=0.01,
47
- label="Product yield: CBx Weight Output / Weight Input (%)",
48
- )
49
- kwh_rate = param.Number(
50
- default=0.25, bounds=(0.01, 5), step=0.01, label="Power rate ($ per kWh)"
51
- )
52
- water_cost_per_1000l = param.Number(
53
- default=2.50,
54
- bounds=(0.01, 10),
55
- step=0.01,
56
- label="Water rate ($ per 1000L / m3)",
57
- )
58
- consumables_per_kg_bio_rate = param.Number(
59
- default=0.0032,
60
- bounds=(0, 10),
61
- step=0.0001,
62
- label="Other Consumables rate ($ per kg biomass)",
63
- )
64
- kwh_per_kg_bio = param.Number(
65
- default=0.25,
66
- bounds=(0.05, 15),
67
- step=0.01,
68
- label="Power consumption (kWh per kg biomass)",
69
- )
70
- water_liters_consumed_per_kg_bio = param.Number(
71
- default=3.0,
72
- bounds=(0.1, 100),
73
- step=0.1,
74
- label="Water consumption (liters per kg biomass)",
75
- )
76
- consumables_per_kg_output = param.Number(
77
- default=10.0,
78
- bounds=(0, 100),
79
- step=0.01,
80
- label="Consumables per kg finished product ($)",
81
- )
82
- bio_cbx_pct = param.Number(
83
- default=10.0, bounds=(0, 30), step=0.1, label="Cannabinoid (CBx) in biomass (%)"
84
- )
85
- bio_cost = param.Number(
86
- default=3.0,
87
- bounds=(0, 200),
88
- step=0.25,
89
- label="Biomass purchase cost ($ per kg)",
90
- )
91
- wholesale_cbx_price = param.Number(
92
- default=220.0,
93
- bounds=(25, 6000),
94
- step=5.0,
95
- label="Gross revenue ($ per kg output)",
96
- )
97
- wholesale_cbx_pct = param.Number(
98
- default=99.9, bounds=(0, 100), step=0.01, label="CBx in finished product (%)"
99
- )
100
- batch_test_cost = param.Number(
101
- default=1300.0,
102
- bounds=(100, 5000),
103
- step=25.0,
104
- label="Per-batch testing/compliance costs ($)",
105
- )
106
- fixed_overhead_per_week = param.Number(
107
- default=2000.0, bounds=(0, 10000), step=1.0, label="Weekly fixed costs ($)"
108
- )
109
- workers_per_shift = param.Number(
110
- default=9.0, bounds=(1, 20), step=1.0, label="Workers per shift"
111
- )
112
- worker_hourly_rate = param.Number(
113
- default=5.0, bounds=(0.25, 50), step=0.25, label="Worker loaded pay rate ($/hr)"
114
- )
115
- managers_per_shift = param.Number(
116
- default=1.0, bounds=(1, 10), step=1.0, label="Supervisors per shift"
117
- )
118
- manager_hourly_rate = param.Number(
119
- default=10.0,
120
- bounds=(5.0, 50),
121
- step=0.25,
122
- label="Supervisor loaded pay rate ($/hr)",
123
- )
124
- processing_hours_per_shift = param.Number(
125
- default=7.0, bounds=(0.25, 8.0), step=0.25, label="Processing hours per shift"
126
- )
127
- labour_hours_per_shift = param.Number(
128
- default=8.0, bounds=(6.0, 12), step=0.25, label="Labor hours per shift"
129
- )
130
- shifts_per_day = param.Number(
131
- default=3.0, bounds=(1, 10), step=1.0, label="Shifts per day"
132
- )
133
- shifts_per_week = param.Number(
134
- default=21.0, bounds=(1, 28), step=1.0, label="Shifts per week"
135
- )
136
-
137
- kg_processed_per_shift = 0.0
138
- labour_cost_per_shift = 0.0
139
- variable_cost_per_shift = 0.0
140
- overhead_cost_per_shift = 0.0
141
- saleable_kg_per_kg_bio = 0.0
142
- saleable_kg_per_shift = 0.0
143
- saleable_kg_per_day = 0.0
144
- saleable_kg_per_week = 0.0
145
- biomass_kg_per_saleable_kg = 0.0
146
- internal_cogs_per_kg_bio = 0.0
147
- internal_cogs_per_shift = 0.0
148
- internal_cogs_per_day = 0.0
149
- internal_cogs_per_week = 0.0
150
- internal_cogs_per_kg_output = 0.0
151
- biomass_cost_per_shift = 0.0
152
- biomass_cost_per_day = 0.0
153
- biomass_cost_per_week = 0.0
154
- biomass_cost_per_kg_output = 0.0
155
- gross_rev_per_kg_bio = 0.0
156
- gross_rev_per_shift = 0.0
157
- gross_rev_per_day = 0.0
158
- gross_rev_per_week = 0.0
159
- net_rev_per_kg_bio = 0.0
160
- net_rev_per_shift = 0.0
161
- net_rev_per_day = 0.0
162
- net_rev_per_week = 0.0
163
- net_rev_per_kg_output = 0.0
164
- operating_profit_pct = 0.0
165
- resin_spread_pct = 0.0
166
-
167
- money_data_df = param.DataFrame(pd.DataFrame())
168
- profit_data_df = param.DataFrame(pd.DataFrame())
169
- processing_data_df = param.DataFrame(pd.DataFrame())
170
-
171
- def __init__(self, **params):
172
- super().__init__(**params)
173
- self._create_sliders()
174
- self.money_table = pn.widgets.Tabulator(
175
- self.money_data_df,
176
- formatters=self._get_money_formatters(),
177
- disabled=True,
178
- layout="fit_data_table",
179
- sizing_mode="fixed",
180
- align="center",
181
- show_index=False, # Hide index column
182
- )
183
- self.profit_table = pn.widgets.Tabulator(
184
- self.profit_data_df,
185
- disabled=True,
186
- layout="fit_data_table",
187
- sizing_mode="fixed",
188
- align="center",
189
- show_index=False, # Hide index column
190
- )
191
- self.processing_table = pn.widgets.Tabulator(
192
- self.processing_data_df,
193
- formatters={},
194
- disabled=True,
195
- layout="fit_data_table",
196
- sizing_mode="fixed",
197
- align="center",
198
- show_index=False, # Hide index column
199
- )
200
- self._update_calculations()
201
-
202
- def _create_sliders(self):
203
- self.kg_processed_per_hour_slider = pn.widgets.EditableFloatSlider.from_param(
204
- self.param.kg_processed_per_hour,
205
- name=self.param.kg_processed_per_hour.label, # Updated
206
- fixed_start=self.param.kg_processed_per_hour.bounds[0],
207
- fixed_end=self.param.kg_processed_per_hour.bounds[1],
208
- design=slider_design,
209
- styles=slider_style,
210
- stylesheets=slider_stylesheet,
211
- # format="0",
212
- format=PrintfTickFormatter(format="%i kg"),
213
- )
214
- self.finished_product_yield_pct_slider = (
215
- pn.widgets.EditableFloatSlider.from_param(
216
- self.param.finished_product_yield_pct,
217
- name=self.param.finished_product_yield_pct.label, # Updated
218
- fixed_start=self.param.finished_product_yield_pct.bounds[0],
219
- fixed_end=self.param.finished_product_yield_pct.bounds[1],
220
- design=slider_design,
221
- styles=slider_style,
222
- stylesheets=slider_stylesheet,
223
- format="0.00",
224
- )
225
- )
226
- self.kwh_rate_slider = pn.widgets.EditableFloatSlider.from_param(
227
- self.param.kwh_rate,
228
- name=self.param.kwh_rate.label, # Updated
229
- fixed_start=self.param.kwh_rate.bounds[0],
230
- fixed_end=self.param.kwh_rate.bounds[1],
231
- design=slider_design,
232
- styles=slider_style,
233
- stylesheets=slider_stylesheet,
234
- format="0.00",
235
- # format=PrintfTickFormatter(format='%.2f per kWh'),
236
- )
237
- self.water_cost_per_1000l_slider = pn.widgets.EditableFloatSlider.from_param(
238
- self.param.water_cost_per_1000l,
239
- name=self.param.water_cost_per_1000l.label, # Updated
240
- fixed_start=self.param.water_cost_per_1000l.bounds[0],
241
- fixed_end=self.param.water_cost_per_1000l.bounds[1],
242
- design=slider_design,
243
- styles=slider_style,
244
- stylesheets=slider_stylesheet,
245
- format="0.00",
246
- )
247
- self.consumables_per_kg_bio_rate_slider = (
248
- pn.widgets.EditableFloatSlider.from_param(
249
- self.param.consumables_per_kg_bio_rate,
250
- name=self.param.consumables_per_kg_bio_rate.label, # Updated
251
- fixed_start=self.param.consumables_per_kg_bio_rate.bounds[0],
252
- fixed_end=self.param.consumables_per_kg_bio_rate.bounds[1],
253
- design=slider_design,
254
- styles=slider_style,
255
- stylesheets=slider_stylesheet,
256
- format="0.0000",
257
- )
258
- )
259
- self.kwh_per_kg_bio_slider = pn.widgets.EditableFloatSlider.from_param(
260
- self.param.kwh_per_kg_bio,
261
- name=self.param.kwh_per_kg_bio.label, # Updated
262
- fixed_start=self.param.kwh_per_kg_bio.bounds[0],
263
- fixed_end=self.param.kwh_per_kg_bio.bounds[1],
264
- design=slider_design,
265
- styles=slider_style,
266
- stylesheets=slider_stylesheet,
267
- format="0.00",
268
- )
269
- self.water_liters_consumed_per_kg_bio_slider = (
270
- pn.widgets.EditableFloatSlider.from_param(
271
- self.param.water_liters_consumed_per_kg_bio,
272
- name=self.param.water_liters_consumed_per_kg_bio.label, # Updated
273
- fixed_start=self.param.water_liters_consumed_per_kg_bio.bounds[0],
274
- fixed_end=self.param.water_liters_consumed_per_kg_bio.bounds[1],
275
- design=slider_design,
276
- styles=slider_style,
277
- stylesheets=slider_stylesheet,
278
- format="0.0",
279
- )
280
- )
281
- self.consumables_per_kg_output_slider = (
282
- pn.widgets.EditableFloatSlider.from_param(
283
- self.param.consumables_per_kg_output,
284
- name=self.param.consumables_per_kg_output.label, # Updated
285
- fixed_start=self.param.consumables_per_kg_output.bounds[0],
286
- fixed_end=self.param.consumables_per_kg_output.bounds[1],
287
- design=slider_design,
288
- styles=slider_style,
289
- stylesheets=slider_stylesheet,
290
- format="0.00",
291
- )
292
- )
293
- self.bio_cbx_pct_slider = pn.widgets.EditableFloatSlider.from_param(
294
- self.param.bio_cbx_pct,
295
- name=self.param.bio_cbx_pct.label, # Updated
296
- fixed_start=self.param.bio_cbx_pct.bounds[0],
297
- fixed_end=self.param.bio_cbx_pct.bounds[1],
298
- design=slider_design,
299
- styles=slider_style,
300
- stylesheets=slider_stylesheet,
301
- format="0.0",
302
- )
303
- self.bio_cost_slider = pn.widgets.EditableFloatSlider.from_param(
304
- self.param.bio_cost,
305
- name=self.param.bio_cost.label, # Updated
306
- fixed_start=self.param.bio_cost.bounds[0],
307
- fixed_end=self.param.bio_cost.bounds[1],
308
- design=slider_design,
309
- styles=slider_style,
310
- stylesheets=slider_stylesheet,
311
- format="0.00",
312
- )
313
- self.wholesale_cbx_price_slider = pn.widgets.EditableFloatSlider.from_param(
314
- self.param.wholesale_cbx_price,
315
- name=self.param.wholesale_cbx_price.label, # Updated
316
- fixed_start=self.param.wholesale_cbx_price.bounds[0],
317
- fixed_end=self.param.wholesale_cbx_price.bounds[1],
318
- design=slider_design,
319
- styles=slider_style,
320
- stylesheets=slider_stylesheet,
321
- format="0",
322
- )
323
- self.wholesale_cbx_pct_slider = pn.widgets.EditableFloatSlider.from_param(
324
- self.param.wholesale_cbx_pct,
325
- name=self.param.wholesale_cbx_pct.label, # Updated
326
- fixed_start=self.param.wholesale_cbx_pct.bounds[0],
327
- fixed_end=self.param.wholesale_cbx_pct.bounds[1],
328
- design=slider_design,
329
- styles=slider_style,
330
- stylesheets=slider_stylesheet,
331
- format="0.00",
332
- )
333
- self.batch_test_cost_slider = pn.widgets.EditableFloatSlider.from_param(
334
- self.param.batch_test_cost,
335
- name=self.param.batch_test_cost.label, # Updated
336
- fixed_start=self.param.batch_test_cost.bounds[0],
337
- fixed_end=self.param.batch_test_cost.bounds[1],
338
- design=slider_design,
339
- styles=slider_style,
340
- stylesheets=slider_stylesheet,
341
- format="0",
342
- )
343
- self.fixed_overhead_per_week_slider = pn.widgets.EditableFloatSlider.from_param(
344
- self.param.fixed_overhead_per_week,
345
- name=self.param.fixed_overhead_per_week.label, # Updated
346
- fixed_start=self.param.fixed_overhead_per_week.bounds[0],
347
- fixed_end=self.param.fixed_overhead_per_week.bounds[1],
348
- design=slider_design,
349
- styles=slider_style,
350
- stylesheets=slider_stylesheet,
351
- format="0",
352
- )
353
- self.workers_per_shift_slider = pn.widgets.EditableFloatSlider.from_param(
354
- self.param.workers_per_shift,
355
- name=self.param.workers_per_shift.label, # Updated
356
- fixed_start=self.param.workers_per_shift.bounds[0],
357
- fixed_end=self.param.workers_per_shift.bounds[1],
358
- design=slider_design,
359
- styles=slider_style,
360
- stylesheets=slider_stylesheet,
361
- format="0",
362
- )
363
- self.worker_hourly_rate_slider = pn.widgets.EditableFloatSlider.from_param(
364
- self.param.worker_hourly_rate,
365
- name=self.param.worker_hourly_rate.label, # Updated
366
- fixed_start=self.param.worker_hourly_rate.bounds[0],
367
- fixed_end=self.param.worker_hourly_rate.bounds[1],
368
- design=slider_design,
369
- styles=slider_style,
370
- stylesheets=slider_stylesheet,
371
- format="0.00",
372
- )
373
- self.managers_per_shift_slider = pn.widgets.EditableFloatSlider.from_param(
374
- self.param.managers_per_shift,
375
- name=self.param.managers_per_shift.label, # Updated
376
- fixed_start=self.param.managers_per_shift.bounds[0],
377
- fixed_end=self.param.managers_per_shift.bounds[1],
378
- design=slider_design,
379
- styles=slider_style,
380
- stylesheets=slider_stylesheet,
381
- format="0",
382
- )
383
- self.manager_hourly_rate_slider = pn.widgets.EditableFloatSlider.from_param(
384
- self.param.manager_hourly_rate,
385
- name=self.param.manager_hourly_rate.label, # Updated
386
- fixed_start=self.param.worker_hourly_rate.default, # Keeping original logic as per file
387
- fixed_end=self.param.manager_hourly_rate.bounds[1],
388
- design=slider_design,
389
- styles=slider_style,
390
- stylesheets=slider_stylesheet,
391
- format="0.00",
392
- )
393
-
394
- # Updated based on previous request
395
- self.labour_hours_per_shift_slider = pn.widgets.EditableFloatSlider.from_param(
396
- self.param.labour_hours_per_shift,
397
- name=self.param.labour_hours_per_shift.label, # Updated
398
- fixed_start=self.param.labour_hours_per_shift.bounds[
399
- 0
400
- ], # Changed in previous request
401
- fixed_end=self.param.labour_hours_per_shift.bounds[1],
402
- design=slider_design,
403
- styles=slider_style,
404
- stylesheets=slider_stylesheet,
405
- format="0.00",
406
- )
407
-
408
- # Updated based on previous request
409
- self.processing_hours_per_shift_slider = (
410
- pn.widgets.EditableFloatSlider.from_param(
411
- self.param.processing_hours_per_shift,
412
- name=self.param.processing_hours_per_shift.label, # Updated
413
- fixed_start=self.param.processing_hours_per_shift.bounds[0],
414
- fixed_end=self.labour_hours_per_shift, # Changed in previous request
415
- design=slider_design,
416
- styles=slider_style,
417
- stylesheets=slider_stylesheet,
418
- format="0.00",
419
- )
420
- )
421
-
422
- self.shifts_per_day_slider = pn.widgets.EditableFloatSlider.from_param(
423
- self.param.shifts_per_day,
424
- name=self.param.shifts_per_day.label, # Updated
425
- fixed_start=self.param.shifts_per_day.bounds[0],
426
- fixed_end=self.param.shifts_per_day.bounds[1],
427
- design=slider_design,
428
- styles=slider_style,
429
- stylesheets=slider_stylesheet,
430
- format="0",
431
- )
432
- self.shifts_per_week_slider = pn.widgets.EditableFloatSlider.from_param(
433
- self.param.shifts_per_week,
434
- name=self.param.shifts_per_week.label, # Updated
435
- fixed_start=self.param.shifts_per_week.bounds[0],
436
- fixed_end=self.param.shifts_per_week.bounds[1],
437
- design=slider_design,
438
- styles=slider_style,
439
- stylesheets=slider_stylesheet,
440
- format="0",
441
- )
442
-
443
- @param.depends(
444
- "kg_processed_per_hour",
445
- "finished_product_yield_pct",
446
- "kwh_rate",
447
- "water_cost_per_1000l",
448
- "consumables_per_kg_bio_rate",
449
- "kwh_per_kg_bio",
450
- "water_liters_consumed_per_kg_bio",
451
- "consumables_per_kg_output",
452
- "bio_cbx_pct",
453
- "bio_cost",
454
- "wholesale_cbx_price",
455
- "wholesale_cbx_pct",
456
- "batch_test_cost",
457
- "fixed_overhead_per_week",
458
- "workers_per_shift",
459
- "worker_hourly_rate",
460
- "managers_per_shift",
461
- "manager_hourly_rate",
462
- "labour_hours_per_shift",
463
- "processing_hours_per_shift",
464
- "shifts_per_day",
465
- "shifts_per_week",
466
- watch=True,
467
- )
468
- def _update_calculations(self, *events):
469
- self.kg_processed_per_shift = (
470
- self.processing_hours_per_shift * self.kg_processed_per_hour
471
- )
472
- if self.shifts_per_week == 0:
473
- self.shifts_per_week = 1
474
-
475
- self._calc_saleable_kg()
476
- self._calc_biomass_cost()
477
- self._calc_cogs()
478
- self._calc_gross_revenue()
479
- self._calc_net_revenue()
480
-
481
- self.operating_profit_pct = (
482
- (self.net_rev_per_kg_bio / self.gross_rev_per_kg_bio)
483
- if self.gross_rev_per_kg_bio
484
- else 0.0
485
- )
486
- self.resin_spread_pct = (
487
- ((self.gross_rev_per_kg_bio - self.bio_cost) / self.bio_cost)
488
- if self.bio_cost
489
- else 0.0
490
- )
491
-
492
- self._update_tables_data()
493
-
494
- @param.depends("labour_hours_per_shift", watch=True)
495
- def _update_processing_hours_slider_constraints(self):
496
- new_max_processing_hours = self.labour_hours_per_shift
497
-
498
- # Get the current lower bound of the processing_hours_per_shift parameter
499
- current_min_processing_hours = self.param.processing_hours_per_shift.bounds[0]
500
-
501
- # Update the bounds of the underlying param.Number object for processing_hours_per_shift
502
- # This allows the parameter to accept values up to the new maximum
503
- self.param.processing_hours_per_shift.bounds = (
504
- current_min_processing_hours,
505
- new_max_processing_hours,
506
- )
507
-
508
- # Ensure the slider widget has been created before trying to access it
509
- if hasattr(self, "processing_hours_per_shift_slider"):
510
- # Update the 'end' property of the slider widget
511
- self.processing_hours_per_shift_slider.end = new_max_processing_hours
512
-
513
- # If the current value of processing_hours_per_shift is now greater than
514
- # the new maximum, adjust it to be the new maximum.
515
- if self.processing_hours_per_shift > new_max_processing_hours:
516
- self.processing_hours_per_shift = new_max_processing_hours
517
-
518
- def _calc_cogs(self):
519
- worker_cost = self.workers_per_shift * self.worker_hourly_rate
520
- manager_cost = self.managers_per_shift * self.manager_hourly_rate
521
- self.labour_cost_per_shift = (
522
- worker_cost + manager_cost
523
- ) * self.labour_hours_per_shift
524
-
525
- power_cost_per_kg = self.kwh_rate * self.kwh_per_kg_bio
526
- water_cost_per_kg = (
527
- self.water_cost_per_1000l / 1000.0
528
- ) * self.water_liters_consumed_per_kg_bio
529
- total_variable_consumable_cost_per_kg = (
530
- self.consumables_per_kg_bio_rate + power_cost_per_kg + water_cost_per_kg
531
- )
532
- self.variable_cost_per_shift = (
533
- total_variable_consumable_cost_per_kg * self.kg_processed_per_shift
534
- )
535
-
536
- self.overhead_cost_per_shift = (
537
- self.fixed_overhead_per_week / self.shifts_per_week
538
- if self.shifts_per_week > 0
539
- else 0.0
540
- )
541
-
542
- shift_cogs_before_output_specific = (
543
- self.labour_cost_per_shift
544
- + self.variable_cost_per_shift
545
- + self.overhead_cost_per_shift
546
- )
547
- shift_output_specific_cogs = (
548
- self.consumables_per_kg_output * self.saleable_kg_per_shift
549
- )
550
-
551
- self.internal_cogs_per_shift = (
552
- shift_cogs_before_output_specific + shift_output_specific_cogs
553
- )
554
- self.internal_cogs_per_kg_bio = (
555
- self.internal_cogs_per_shift / self.kg_processed_per_shift
556
- if self.kg_processed_per_shift > 0
557
- else 0.0
558
- )
559
- self.internal_cogs_per_day = self.internal_cogs_per_shift * self.shifts_per_day
560
- self.internal_cogs_per_week = (
561
- self.internal_cogs_per_shift * self.shifts_per_week
562
- )
563
- self.internal_cogs_per_kg_output = (
564
- (self.internal_cogs_per_kg_bio * self.biomass_kg_per_saleable_kg)
565
- if self.biomass_kg_per_saleable_kg != 0
566
- else 0.0
567
- )
568
-
569
- def _calc_gross_revenue(self):
570
- self.gross_rev_per_kg_bio = (
571
- self.saleable_kg_per_kg_bio * self.wholesale_cbx_price
572
- )
573
- self.gross_rev_per_shift = (
574
- self.gross_rev_per_kg_bio * self.kg_processed_per_shift
575
- )
576
- self.gross_rev_per_day = self.gross_rev_per_shift * self.shifts_per_day
577
- self.gross_rev_per_week = self.gross_rev_per_shift * self.shifts_per_week
578
-
579
- def _calc_net_revenue(self):
580
- self.net_rev_per_kg_bio = (
581
- self.gross_rev_per_kg_bio - self.internal_cogs_per_kg_bio - self.bio_cost
582
- )
583
- self.net_rev_per_shift = self.net_rev_per_kg_bio * self.kg_processed_per_shift
584
- self.net_rev_per_day = self.net_rev_per_shift * self.shifts_per_day
585
- self.net_rev_per_week = self.net_rev_per_shift * self.shifts_per_week
586
- self.net_rev_per_kg_output = (
587
- (self.biomass_kg_per_saleable_kg * self.net_rev_per_kg_bio)
588
- if self.biomass_kg_per_saleable_kg != 0
589
- else 0.0
590
- )
591
-
592
- def _calc_biomass_cost(self):
593
- self.biomass_cost_per_shift = self.kg_processed_per_shift * self.bio_cost
594
- self.biomass_cost_per_day = self.biomass_cost_per_shift * self.shifts_per_day
595
- self.biomass_cost_per_week = self.biomass_cost_per_shift * self.shifts_per_week
596
-
597
- def _calc_saleable_kg(self):
598
- if self.wholesale_cbx_pct == 0:
599
- self.saleable_kg_per_kg_bio = 0.0
600
- else:
601
- self.saleable_kg_per_kg_bio = (
602
- (self.bio_cbx_pct / 100.0)
603
- * (self.finished_product_yield_pct / 100.0)
604
- / (self.wholesale_cbx_pct / 100.0)
605
- )
606
- self.saleable_kg_per_shift = (
607
- self.saleable_kg_per_kg_bio * self.kg_processed_per_shift
608
- )
609
- self.saleable_kg_per_day = self.saleable_kg_per_shift * self.shifts_per_day
610
- self.saleable_kg_per_week = self.saleable_kg_per_shift * self.shifts_per_week
611
- self.biomass_kg_per_saleable_kg = (
612
- 1 / self.saleable_kg_per_kg_bio if self.saleable_kg_per_kg_bio > 0 else 0.0
613
- )
614
- self.biomass_cost_per_kg_output = (
615
- self.biomass_kg_per_saleable_kg * self.bio_cost
616
- )
617
-
618
- def _update_tables_data(self):
619
- money_data_dict = {
620
- " ": ["Biomass cost", "Processing cost", "Gross Revenue", "Net Revenue"],
621
- "Per Kilogram of Biomass": [
622
- self.bio_cost,
623
- self.internal_cogs_per_kg_bio,
624
- self.gross_rev_per_kg_bio,
625
- self.net_rev_per_kg_bio,
626
- ],
627
- "Per Kilogram of Output": [
628
- self.biomass_cost_per_kg_output,
629
- self.internal_cogs_per_kg_output,
630
- self.wholesale_cbx_price,
631
- self.net_rev_per_kg_output,
632
- ],
633
- "Per Shift": [
634
- self.biomass_cost_per_shift,
635
- self.internal_cogs_per_shift,
636
- self.gross_rev_per_shift,
637
- self.net_rev_per_shift,
638
- ],
639
- "Per Day": [
640
- self.biomass_cost_per_day,
641
- self.internal_cogs_per_day,
642
- self.gross_rev_per_day,
643
- self.net_rev_per_day,
644
- ],
645
- "Per Week": [
646
- self.biomass_cost_per_week,
647
- self.internal_cogs_per_week,
648
- self.gross_rev_per_week,
649
- self.net_rev_per_week,
650
- ],
651
- }
652
- self.money_data_df = pd.DataFrame(money_data_dict)
653
- if hasattr(self, "money_table"):
654
- self.money_table.value = self.money_data_df
655
-
656
- profit_data_dict = {
657
- "Metric": ["Operating Profit", "Resin Spread"],
658
- "Value": [
659
- f"{self.operating_profit_pct * 100.0:.2f}%",
660
- f"{self.resin_spread_pct * 100.0:.2f}%",
661
- ],
662
- }
663
- self.profit_data_df = pd.DataFrame(profit_data_dict)
664
- if hasattr(self, "profit_table"):
665
- self.profit_table.value = self.profit_data_df
666
-
667
- processing_values_formatted = [
668
- f"{self.kg_processed_per_shift:,.0f}",
669
- f"${self.labour_cost_per_shift:,.2f}",
670
- f"${self.variable_cost_per_shift:,.2f}",
671
- f"${self.overhead_cost_per_shift:,.2f}",
672
- ]
673
- processing_data_dict = {
674
- "Metric": [
675
- "Kilograms Extracted per Shift",
676
- "Labour Cost per Shift",
677
- "Variable Cost per Shift",
678
- "Overhead per Shift",
679
- ],
680
- "Value": processing_values_formatted,
681
- }
682
- self.processing_data_df = pd.DataFrame(processing_data_dict)
683
- if hasattr(self, "processing_table"):
684
- self.processing_table.value = self.processing_data_df
685
-
686
- def _get_money_formatters(self):
687
- return {
688
- "Per Kilogram of Biomass": get_formatter("$%.02f"),
689
- "Per Kilogram of Output": get_formatter("$%.02f"),
690
- "Per Shift": get_formatter("$%.02f"),
691
- "Per Day": get_formatter("$%.02f"),
692
- "Per Week": get_formatter("$%.02f"),
693
- }
694
-
695
- def view(self):
696
- col1 = pn.Column(
697
- pn.pane.Markdown("## Extraction"),
698
- pn.Row(
699
- self.kg_processed_per_hour_slider, # Updated
700
- ),
701
- pn.Row(
702
- self.finished_product_yield_pct_slider, # Updated
703
- ),
704
- pn.pane.Markdown("## Biomass parameters"),
705
- pn.Row(
706
- self.bio_cbx_pct_slider, # Updated
707
- ),
708
- pn.Row(
709
- self.bio_cost_slider, # Updated
710
- ),
711
- sizing_mode="stretch_width",
712
- )
713
- col2 = pn.Column(
714
- pn.pane.Markdown("## Consumable rates"),
715
- pn.Row(
716
- self.kwh_rate_slider, # Updated
717
- ),
718
- pn.Row(
719
- self.water_cost_per_1000l_slider, # Updated
720
- ),
721
- pn.Row(
722
- self.consumables_per_kg_bio_rate_slider, # Updated
723
- ),
724
- pn.pane.Markdown("## Wholesale details"),
725
- pn.Row(
726
- self.wholesale_cbx_price_slider, # Updated
727
- ),
728
- pn.Row(
729
- self.wholesale_cbx_pct_slider, # Updated
730
- ),
731
- sizing_mode="stretch_width",
732
- )
733
- col3 = pn.Column(
734
- pn.pane.Markdown("## Variable costs"),
735
- pn.Row(
736
- self.kwh_per_kg_bio_slider, # Updated
737
- ),
738
- pn.Row(
739
- self.water_liters_consumed_per_kg_bio_slider, # Updated
740
- ),
741
- pn.Row(
742
- self.consumables_per_kg_output_slider, # Updated
743
- ),
744
- pn.pane.Markdown("## Compliance"),
745
- pn.Row(
746
- self.batch_test_cost_slider, # Updated
747
- ),
748
- pn.pane.Markdown("## Overhead"),
749
- pn.Row(
750
- self.fixed_overhead_per_week_slider, # Updated
751
- ),
752
- sizing_mode="stretch_width",
753
- )
754
- col4 = pn.Column(
755
- pn.pane.Markdown("## Worker Details"),
756
- pn.Row(
757
- self.workers_per_shift_slider, # Updated
758
- ),
759
- pn.Row(
760
- self.worker_hourly_rate_slider, # Updated
761
- ),
762
- pn.Row(
763
- self.managers_per_shift_slider, # Updated
764
- ),
765
- pn.Row(
766
- self.manager_hourly_rate_slider, # Updated
767
- ),
768
- pn.pane.Markdown("## Shift details"),
769
- pn.Row(
770
- self.labour_hours_per_shift_slider, # Updated
771
- ),
772
- pn.Row(
773
- self.processing_hours_per_shift_slider, # Updated
774
- ),
775
- pn.Row(
776
- self.shifts_per_day_slider, # Updated
777
- ),
778
- pn.Row(
779
- self.shifts_per_week_slider, # Updated
780
- ),
781
- sizing_mode="stretch_width",
782
- )
783
-
784
- input_grid = pn.GridSpec(sizing_mode="stretch_width", max_width=1800, margin=10)
785
- input_grid[0, 0] = col1
786
- input_grid[0, 1] = col2
787
- input_grid[0, 2] = col3
788
- input_grid[0, 3] = col4
789
-
790
- money_table_display = pn.Column(
791
- pn.pane.Markdown("### Financial Summary", styles={"text-align": "center"}),
792
- self.money_table,
793
- )
794
-
795
- profit_table_display = pn.Column(
796
- pn.pane.Markdown(
797
- "### Profitability Metrics", styles={"text-align": "center"}
798
- ),
799
- self.profit_table,
800
- )
801
-
802
- processing_table_display = pn.Column(
803
- pn.pane.Markdown("### Processing Summary", styles={"text-align": "center"}),
804
- self.processing_table,
805
- )
806
-
807
- tables_layout = pn.Column(
808
- money_table_display,
809
- pn.Row(
810
- processing_table_display,
811
- profit_table_display,
812
- align="center",
813
- ),
814
- #sizing_mode="stretch_width",
815
- #max_width=1800,
816
- margin=10,
817
- align="center",
818
- )
819
-
820
- profit_weekly = pn.indicators.Number(
821
- name="Weekly Profit",
822
- value=self.net_rev_per_week,
823
- format=f"${self.net_rev_per_week / 1000:.0f} k",
824
- default_color="green",
825
- align="center",
826
- )
827
-
828
- profit_pct = pn.indicators.Number(
829
- name="Operating Profit",
830
- value=self.operating_profit_pct,
831
- format=f"{self.operating_profit_pct * 100.0:.2f}%",
832
- default_color="green",
833
- align="center",
834
- )
835
-
836
- indicator_layout = pn.Column(profit_pct, profit_weekly, align="center")
837
-
838
- table_grid = pn.GridSpec(sizing_mode="stretch_width", max_width=1800, margin=10)
839
- table_grid[:, 0:2] = tables_layout
840
- table_grid[:, 2] = indicator_layout
841
-
842
- main_layout = pn.Column(
843
- input_grid,
844
- pn.layout.Divider(margin=(10, 0)),
845
- table_grid,
846
- styles={"margin": "0px 10px"},
847
- )
848
-
849
- return main_layout
850
-
851
-
852
- estimator_app = CannabinoidEstimator()
853
- # To run in a Panel server:
854
- # pn.config.raw_css = custom_themes.get_base_css(custom_themes.DARK_THEME_VARS)
855
- estimator_app.view().servable(title="CBx Revenue Estimator")
856
-
857
- # Instantiate the template with widgets displayed in the sidebar
858
- # template = pn.template.FastListTemplate(
859
- # title="CBx Revenue Estimator (FastList Panel)",
860
- # #theme = custom_themes.DarkTheme,
861
- # #sidebar=[freq, phase],
862
- # )
863
-
864
- # template.main.append(estimator_app.view())
865
- # template.servable()
866
-
867
- if __name__ == "__main__":
868
- pn.serve(
869
- estimator_app.view(),
870
- title="CBx Revenue Estimator (Panel)",
871
- show=True,
872
- port=5007,
873
- )
 
1
+ import panel as pn
2
+ import pandas as pd
3
+ import param
4
+ from bokeh.models.formatters import PrintfTickFormatter
5
+ # from custom_themes import AIDefaultTheme, AIDarkTheme
6
+
7
+
8
+ # Initialize Panel extension for Tabulator and set a global sizing mode
9
+ pn.extension(
10
+ "tabulator",
11
+ sizing_mode="stretch_width",
12
+ template="fast",
13
+ # theme = AIDarkTheme,
14
+ )
15
+
16
+ # --- Styling Placeholders (as per user instruction) ---
17
+ slider_design = {}
18
+ slider_style = {}
19
+ slider_stylesheet = []
20
+
21
+
22
+ # --- Helper for NumberFormatters ---
23
+ def get_formatter(format_str):
24
+ if format_str == "%i":
25
+ return pn.widgets.tables.NumberFormatter(format="0")
26
+ elif format_str == "%.1f":
27
+ return pn.widgets.tables.NumberFormatter(format="0.0")
28
+ elif format_str == "%.2f":
29
+ return pn.widgets.tables.NumberFormatter(format="0.00")
30
+ elif format_str == "%.4f":
31
+ return pn.widgets.tables.NumberFormatter(format="0.0000")
32
+ elif format_str == "$%.02f":
33
+ return pn.widgets.tables.NumberFormatter(format="$0,0.00")
34
+ return format_str
35
+
36
+
37
+ class CannabinoidEstimator(param.Parameterized):
38
+ # --- Input Parameters ---
39
+ kg_processed_per_hour = param.Number(
40
+ default=150.0,
41
+ bounds=(0, 2000),
42
+ step=1.0,
43
+ label="Biomass processed per hour (kg)",
44
+ )
45
+ finished_product_yield_pct = param.Number(
46
+ default=60.0,
47
+ bounds=(0.01, 100),
48
+ step=0.01,
49
+ label="Product yield: CBx Weight Output / Weight Input (%)",
50
+ )
51
+ kwh_rate = param.Number(
52
+ default=0.25, bounds=(0.01, 5), step=0.01, label="Power rate ($ per kWh)"
53
+ )
54
+ water_cost_per_1000l = param.Number(
55
+ default=2.50,
56
+ bounds=(0.01, 10),
57
+ step=0.01,
58
+ label="Water rate ($ per 1000L / m3)",
59
+ )
60
+ consumables_per_kg_bio_rate = param.Number(
61
+ default=0.0032,
62
+ bounds=(0, 10),
63
+ step=0.0001,
64
+ label="Other Consumables rate ($ per kg biomass)",
65
+ )
66
+ kwh_per_kg_bio = param.Number(
67
+ default=0.25,
68
+ bounds=(0.05, 15),
69
+ step=0.01,
70
+ label="Power consumption (kWh per kg biomass)",
71
+ )
72
+ water_liters_consumed_per_kg_bio = param.Number(
73
+ default=3.0,
74
+ bounds=(0.1, 100),
75
+ step=0.1,
76
+ label="Water consumption (liters per kg biomass)",
77
+ )
78
+ consumables_per_kg_output = param.Number(
79
+ default=10.0,
80
+ bounds=(0, 100),
81
+ step=0.01,
82
+ label="Consumables per kg finished product ($)",
83
+ )
84
+ bio_cbx_pct = param.Number(
85
+ default=10.0, bounds=(0, 30), step=0.1, label="Cannabinoid (CBx) in biomass (%)"
86
+ )
87
+ bio_cost = param.Number(
88
+ default=3.0,
89
+ bounds=(0, 200),
90
+ step=0.25,
91
+ label="Biomass purchase cost ($ per kg)",
92
+ )
93
+ wholesale_cbx_price = param.Number(
94
+ default=220.0,
95
+ bounds=(25, 6000),
96
+ step=5.0,
97
+ label="Gross revenue ($ per kg output)",
98
+ )
99
+ wholesale_cbx_pct = param.Number(
100
+ default=99.9, bounds=(0, 100), step=0.01, label="CBx in finished product (%)"
101
+ )
102
+ batch_test_cost = param.Number(
103
+ default=1300.0,
104
+ bounds=(100, 5000),
105
+ step=25.0,
106
+ label="Per-batch testing/compliance costs ($)",
107
+ )
108
+ fixed_overhead_per_week = param.Number(
109
+ default=2000.0, bounds=(0, 10000), step=1.0, label="Weekly fixed costs ($)"
110
+ )
111
+ workers_per_shift = param.Number(
112
+ default=9.0, bounds=(1, 20), step=1.0, label="Workers per shift"
113
+ )
114
+ worker_hourly_rate = param.Number(
115
+ default=5.0, bounds=(0.25, 50), step=0.25, label="Worker loaded pay rate ($/hr)"
116
+ )
117
+ managers_per_shift = param.Number(
118
+ default=1.0, bounds=(1, 10), step=1.0, label="Supervisors per shift"
119
+ )
120
+ manager_hourly_rate = param.Number(
121
+ default=10.0,
122
+ bounds=(5.0, 50),
123
+ step=0.25,
124
+ label="Supervisor loaded pay rate ($/hr)",
125
+ )
126
+ processing_hours_per_shift = param.Number(
127
+ default=7.0, bounds=(0.25, 8.0), step=0.25, label="Processing hours per shift"
128
+ )
129
+ labour_hours_per_shift = param.Number(
130
+ default=8.0, bounds=(6.0, 12), step=0.25, label="Labor hours per shift"
131
+ )
132
+ shifts_per_day = param.Number(
133
+ default=3.0, bounds=(1, 10), step=1.0, label="Shifts per day"
134
+ )
135
+ shifts_per_week = param.Number(
136
+ default=21.0, bounds=(1, 28), step=1.0, label="Shifts per week"
137
+ )
138
+
139
+ kg_processed_per_shift = 0.0
140
+ labour_cost_per_shift = 0.0
141
+ variable_cost_per_shift = 0.0
142
+ overhead_cost_per_shift = 0.0
143
+ saleable_kg_per_kg_bio = 0.0
144
+ saleable_kg_per_shift = 0.0
145
+ saleable_kg_per_day = 0.0
146
+ saleable_kg_per_week = 0.0
147
+ biomass_kg_per_saleable_kg = 0.0
148
+ internal_cogs_per_kg_bio = 0.0
149
+ internal_cogs_per_shift = 0.0
150
+ internal_cogs_per_day = 0.0
151
+ internal_cogs_per_week = 0.0
152
+ internal_cogs_per_kg_output = 0.0
153
+ biomass_cost_per_shift = 0.0
154
+ biomass_cost_per_day = 0.0
155
+ biomass_cost_per_week = 0.0
156
+ biomass_cost_per_kg_output = 0.0
157
+ gross_rev_per_kg_bio = 0.0
158
+ gross_rev_per_shift = 0.0
159
+ gross_rev_per_day = 0.0
160
+ gross_rev_per_week = 0.0
161
+ net_rev_per_kg_bio = 0.0
162
+ net_rev_per_shift = 0.0
163
+ net_rev_per_day = 0.0
164
+ net_rev_per_week = 0.0
165
+ net_rev_per_kg_output = 0.0
166
+ operating_profit_pct = 0.0
167
+ resin_spread_pct = 0.0
168
+
169
+ money_data_df = param.DataFrame(pd.DataFrame())
170
+ profit_data_df = param.DataFrame(pd.DataFrame())
171
+ processing_data_df = param.DataFrame(pd.DataFrame())
172
+
173
+ def __init__(self, **params):
174
+ super().__init__(**params)
175
+ self._create_sliders()
176
+ self.money_table = pn.widgets.Tabulator(
177
+ self.money_data_df,
178
+ formatters=self._get_money_formatters(),
179
+ disabled=True,
180
+ layout="fit_data",
181
+ sizing_mode="fixed",
182
+ align="center",
183
+ show_index=False, # Hide index column
184
+ text_align={
185
+ " ": "right",
186
+ "$/kg Biomass": "center",
187
+ "$/kg Output": "center",
188
+ "Per Shift": "center",
189
+ "Per Day": "center",
190
+ "Per Week": "center",
191
+ },
192
+ )
193
+ self.profit_table = pn.widgets.Tabulator(
194
+ self.profit_data_df,
195
+ disabled=True,
196
+ layout="fit_data_table",
197
+ sizing_mode="fixed",
198
+ align="center",
199
+ show_index=False, # Hide index column
200
+ text_align={
201
+ "Metric": "right",
202
+ "Value": "center",
203
+ },
204
+ )
205
+ self.processing_table = pn.widgets.Tabulator(
206
+ self.processing_data_df,
207
+ formatters={},
208
+ disabled=True,
209
+ layout="fit_data_table",
210
+ sizing_mode="fixed",
211
+ align="center",
212
+ show_index=False, # Hide index column
213
+ text_align={
214
+ "Metric (Per Shift)": "right",
215
+ "Value": "center",
216
+ },
217
+ )
218
+ self._update_calculations()
219
+
220
+ def _create_sliders(self):
221
+ self.kg_processed_per_hour_slider = pn.widgets.EditableFloatSlider.from_param(
222
+ self.param.kg_processed_per_hour,
223
+ name=self.param.kg_processed_per_hour.label,
224
+ fixed_start=self.param.kg_processed_per_hour.bounds[0],
225
+ fixed_end=self.param.kg_processed_per_hour.bounds[1],
226
+ design=slider_design,
227
+ styles=slider_style,
228
+ stylesheets=slider_stylesheet,
229
+ # format="0",
230
+ format=PrintfTickFormatter(format="%i kg"),
231
+ )
232
+ self.finished_product_yield_pct_slider = (
233
+ pn.widgets.EditableFloatSlider.from_param(
234
+ self.param.finished_product_yield_pct,
235
+ name=self.param.finished_product_yield_pct.label,
236
+ fixed_start=self.param.finished_product_yield_pct.bounds[0],
237
+ fixed_end=self.param.finished_product_yield_pct.bounds[1],
238
+ design=slider_design,
239
+ styles=slider_style,
240
+ stylesheets=slider_stylesheet,
241
+ format="0.00",
242
+ )
243
+ )
244
+ self.kwh_rate_slider = pn.widgets.EditableFloatSlider.from_param(
245
+ self.param.kwh_rate,
246
+ name=self.param.kwh_rate.label,
247
+ fixed_start=self.param.kwh_rate.bounds[0],
248
+ fixed_end=self.param.kwh_rate.bounds[1],
249
+ design=slider_design,
250
+ styles=slider_style,
251
+ stylesheets=slider_stylesheet,
252
+ format="0.00",
253
+ # format=PrintfTickFormatter(format='%.2f per kWh'),
254
+ )
255
+ self.water_cost_per_1000l_slider = pn.widgets.EditableFloatSlider.from_param(
256
+ self.param.water_cost_per_1000l,
257
+ name=self.param.water_cost_per_1000l.label,
258
+ fixed_start=self.param.water_cost_per_1000l.bounds[0],
259
+ fixed_end=self.param.water_cost_per_1000l.bounds[1],
260
+ design=slider_design,
261
+ styles=slider_style,
262
+ stylesheets=slider_stylesheet,
263
+ format="0.00",
264
+ )
265
+ self.consumables_per_kg_bio_rate_slider = (
266
+ pn.widgets.EditableFloatSlider.from_param(
267
+ self.param.consumables_per_kg_bio_rate,
268
+ name=self.param.consumables_per_kg_bio_rate.label,
269
+ fixed_start=self.param.consumables_per_kg_bio_rate.bounds[0],
270
+ fixed_end=self.param.consumables_per_kg_bio_rate.bounds[1],
271
+ design=slider_design,
272
+ styles=slider_style,
273
+ stylesheets=slider_stylesheet,
274
+ format="0.0000",
275
+ )
276
+ )
277
+ self.kwh_per_kg_bio_slider = pn.widgets.EditableFloatSlider.from_param(
278
+ self.param.kwh_per_kg_bio,
279
+ name=self.param.kwh_per_kg_bio.label,
280
+ fixed_start=self.param.kwh_per_kg_bio.bounds[0],
281
+ fixed_end=self.param.kwh_per_kg_bio.bounds[1],
282
+ design=slider_design,
283
+ styles=slider_style,
284
+ stylesheets=slider_stylesheet,
285
+ format="0.00",
286
+ )
287
+ self.water_liters_consumed_per_kg_bio_slider = (
288
+ pn.widgets.EditableFloatSlider.from_param(
289
+ self.param.water_liters_consumed_per_kg_bio,
290
+ name=self.param.water_liters_consumed_per_kg_bio.label,
291
+ fixed_start=self.param.water_liters_consumed_per_kg_bio.bounds[0],
292
+ fixed_end=self.param.water_liters_consumed_per_kg_bio.bounds[1],
293
+ design=slider_design,
294
+ styles=slider_style,
295
+ stylesheets=slider_stylesheet,
296
+ format="0.0",
297
+ )
298
+ )
299
+ self.consumables_per_kg_output_slider = (
300
+ pn.widgets.EditableFloatSlider.from_param(
301
+ self.param.consumables_per_kg_output,
302
+ name=self.param.consumables_per_kg_output.label,
303
+ fixed_start=self.param.consumables_per_kg_output.bounds[0],
304
+ fixed_end=self.param.consumables_per_kg_output.bounds[1],
305
+ design=slider_design,
306
+ styles=slider_style,
307
+ stylesheets=slider_stylesheet,
308
+ format="0.00",
309
+ )
310
+ )
311
+ self.bio_cbx_pct_slider = pn.widgets.EditableFloatSlider.from_param(
312
+ self.param.bio_cbx_pct,
313
+ name=self.param.bio_cbx_pct.label,
314
+ fixed_start=self.param.bio_cbx_pct.bounds[0],
315
+ fixed_end=self.param.bio_cbx_pct.bounds[1],
316
+ design=slider_design,
317
+ styles=slider_style,
318
+ stylesheets=slider_stylesheet,
319
+ format="0.0",
320
+ )
321
+ self.bio_cost_slider = pn.widgets.EditableFloatSlider.from_param(
322
+ self.param.bio_cost,
323
+ name=self.param.bio_cost.label,
324
+ fixed_start=self.param.bio_cost.bounds[0],
325
+ fixed_end=self.param.bio_cost.bounds[1],
326
+ design=slider_design,
327
+ styles=slider_style,
328
+ stylesheets=slider_stylesheet,
329
+ format="0.00",
330
+ )
331
+ self.wholesale_cbx_price_slider = pn.widgets.EditableFloatSlider.from_param(
332
+ self.param.wholesale_cbx_price,
333
+ name=self.param.wholesale_cbx_price.label,
334
+ fixed_start=self.param.wholesale_cbx_price.bounds[0],
335
+ fixed_end=self.param.wholesale_cbx_price.bounds[1],
336
+ design=slider_design,
337
+ styles=slider_style,
338
+ stylesheets=slider_stylesheet,
339
+ format="0",
340
+ )
341
+ self.wholesale_cbx_pct_slider = pn.widgets.EditableFloatSlider.from_param(
342
+ self.param.wholesale_cbx_pct,
343
+ name=self.param.wholesale_cbx_pct.label,
344
+ fixed_start=self.param.wholesale_cbx_pct.bounds[0],
345
+ fixed_end=self.param.wholesale_cbx_pct.bounds[1],
346
+ design=slider_design,
347
+ styles=slider_style,
348
+ stylesheets=slider_stylesheet,
349
+ format="0.00",
350
+ )
351
+ self.batch_test_cost_slider = pn.widgets.EditableFloatSlider.from_param(
352
+ self.param.batch_test_cost,
353
+ name=self.param.batch_test_cost.label,
354
+ fixed_start=self.param.batch_test_cost.bounds[0],
355
+ fixed_end=self.param.batch_test_cost.bounds[1],
356
+ design=slider_design,
357
+ styles=slider_style,
358
+ stylesheets=slider_stylesheet,
359
+ format="0",
360
+ )
361
+ self.fixed_overhead_per_week_slider = pn.widgets.EditableFloatSlider.from_param(
362
+ self.param.fixed_overhead_per_week,
363
+ name=self.param.fixed_overhead_per_week.label,
364
+ fixed_start=self.param.fixed_overhead_per_week.bounds[0],
365
+ fixed_end=self.param.fixed_overhead_per_week.bounds[1],
366
+ design=slider_design,
367
+ styles=slider_style,
368
+ stylesheets=slider_stylesheet,
369
+ format="0",
370
+ )
371
+ self.workers_per_shift_slider = pn.widgets.EditableFloatSlider.from_param(
372
+ self.param.workers_per_shift,
373
+ name=self.param.workers_per_shift.label,
374
+ fixed_start=self.param.workers_per_shift.bounds[0],
375
+ fixed_end=self.param.workers_per_shift.bounds[1],
376
+ design=slider_design,
377
+ styles=slider_style,
378
+ stylesheets=slider_stylesheet,
379
+ format="0",
380
+ )
381
+ self.worker_hourly_rate_slider = pn.widgets.EditableFloatSlider.from_param(
382
+ self.param.worker_hourly_rate,
383
+ name=self.param.worker_hourly_rate.label,
384
+ fixed_start=self.param.worker_hourly_rate.bounds[0],
385
+ fixed_end=self.param.worker_hourly_rate.bounds[1],
386
+ design=slider_design,
387
+ styles=slider_style,
388
+ stylesheets=slider_stylesheet,
389
+ format="0.00",
390
+ )
391
+ self.managers_per_shift_slider = pn.widgets.EditableFloatSlider.from_param(
392
+ self.param.managers_per_shift,
393
+ name=self.param.managers_per_shift.label,
394
+ fixed_start=self.param.managers_per_shift.bounds[0],
395
+ fixed_end=self.param.managers_per_shift.bounds[1],
396
+ design=slider_design,
397
+ styles=slider_style,
398
+ stylesheets=slider_stylesheet,
399
+ format="0",
400
+ )
401
+ self.manager_hourly_rate_slider = pn.widgets.EditableFloatSlider.from_param(
402
+ self.param.manager_hourly_rate,
403
+ name=self.param.manager_hourly_rate.label,
404
+ fixed_start=self.param.worker_hourly_rate.default, # Keeping original logic as per file
405
+ fixed_end=self.param.manager_hourly_rate.bounds[1],
406
+ design=slider_design,
407
+ styles=slider_style,
408
+ stylesheets=slider_stylesheet,
409
+ format="0.00",
410
+ )
411
+
412
+ self.labour_hours_per_shift_slider = pn.widgets.EditableFloatSlider.from_param(
413
+ self.param.labour_hours_per_shift,
414
+ name=self.param.labour_hours_per_shift.label,
415
+ fixed_start=self.param.labour_hours_per_shift.bounds[
416
+ 0
417
+ ], # Changed in previous request
418
+ fixed_end=self.param.labour_hours_per_shift.bounds[1],
419
+ design=slider_design,
420
+ styles=slider_style,
421
+ stylesheets=slider_stylesheet,
422
+ format="0.00",
423
+ )
424
+
425
+ self.processing_hours_per_shift_slider = (
426
+ pn.widgets.EditableFloatSlider.from_param(
427
+ self.param.processing_hours_per_shift,
428
+ name=self.param.processing_hours_per_shift.label,
429
+ fixed_start=self.param.processing_hours_per_shift.bounds[0],
430
+ fixed_end=self.labour_hours_per_shift, # Changed in previous request
431
+ design=slider_design,
432
+ styles=slider_style,
433
+ stylesheets=slider_stylesheet,
434
+ format="0.00",
435
+ )
436
+ )
437
+
438
+ self.shifts_per_day_slider = pn.widgets.EditableFloatSlider.from_param(
439
+ self.param.shifts_per_day,
440
+ name=self.param.shifts_per_day.label,
441
+ fixed_start=self.param.shifts_per_day.bounds[0],
442
+ fixed_end=self.param.shifts_per_day.bounds[1],
443
+ design=slider_design,
444
+ styles=slider_style,
445
+ stylesheets=slider_stylesheet,
446
+ format="0",
447
+ )
448
+ self.shifts_per_week_slider = pn.widgets.EditableFloatSlider.from_param(
449
+ self.param.shifts_per_week,
450
+ name=self.param.shifts_per_week.label,
451
+ fixed_start=self.param.shifts_per_week.bounds[0],
452
+ fixed_end=self.param.shifts_per_week.bounds[1],
453
+ design=slider_design,
454
+ styles=slider_style,
455
+ stylesheets=slider_stylesheet,
456
+ format="0",
457
+ )
458
+
459
+ @param.depends(
460
+ "kg_processed_per_hour",
461
+ "finished_product_yield_pct",
462
+ "kwh_rate",
463
+ "water_cost_per_1000l",
464
+ "consumables_per_kg_bio_rate",
465
+ "kwh_per_kg_bio",
466
+ "water_liters_consumed_per_kg_bio",
467
+ "consumables_per_kg_output",
468
+ "bio_cbx_pct",
469
+ "bio_cost",
470
+ "wholesale_cbx_price",
471
+ "wholesale_cbx_pct",
472
+ "batch_test_cost",
473
+ "fixed_overhead_per_week",
474
+ "workers_per_shift",
475
+ "worker_hourly_rate",
476
+ "managers_per_shift",
477
+ "manager_hourly_rate",
478
+ "labour_hours_per_shift",
479
+ "processing_hours_per_shift",
480
+ "shifts_per_day",
481
+ "shifts_per_week",
482
+ watch=True,
483
+ )
484
+ def _update_calculations(self, *events):
485
+ self.kg_processed_per_shift = (
486
+ self.processing_hours_per_shift * self.kg_processed_per_hour
487
+ )
488
+ if self.shifts_per_week == 0:
489
+ self.shifts_per_week = 1
490
+
491
+ self._calc_saleable_kg()
492
+ self._calc_biomass_cost()
493
+ self._calc_cogs()
494
+ self._calc_gross_revenue()
495
+ self._calc_net_revenue()
496
+
497
+ self.operating_profit_pct = (
498
+ (self.net_rev_per_kg_bio / self.gross_rev_per_kg_bio)
499
+ if self.gross_rev_per_kg_bio
500
+ else 0.0
501
+ )
502
+ self.resin_spread_pct = (
503
+ ((self.gross_rev_per_kg_bio - self.bio_cost) / self.bio_cost)
504
+ if self.bio_cost
505
+ else 0.0
506
+ )
507
+
508
+ self._update_tables_data()
509
+
510
+ @param.depends("labour_hours_per_shift", watch=True)
511
+ def _update_processing_hours_slider_constraints(self):
512
+ new_max_processing_hours = self.labour_hours_per_shift
513
+
514
+ # Get the current lower bound of the processing_hours_per_shift parameter
515
+ current_min_processing_hours = self.param.processing_hours_per_shift.bounds[0]
516
+
517
+ # Update the bounds of the underlying param.Number object for processing_hours_per_shift
518
+ # This allows the parameter to accept values up to the new maximum
519
+ self.param.processing_hours_per_shift.bounds = (
520
+ current_min_processing_hours,
521
+ new_max_processing_hours,
522
+ )
523
+
524
+ # Ensure the slider widget has been created before trying to access it
525
+ if hasattr(self, "processing_hours_per_shift_slider"):
526
+ # Update the 'end' property of the slider widget
527
+ self.processing_hours_per_shift_slider.end = new_max_processing_hours
528
+
529
+ # If the current value of processing_hours_per_shift is now greater than
530
+ # the new maximum, adjust it to be the new maximum.
531
+ if self.processing_hours_per_shift > new_max_processing_hours:
532
+ self.processing_hours_per_shift = new_max_processing_hours
533
+
534
+ def _calc_cogs(self):
535
+ worker_cost = self.workers_per_shift * self.worker_hourly_rate
536
+ manager_cost = self.managers_per_shift * self.manager_hourly_rate
537
+ self.labour_cost_per_shift = (
538
+ worker_cost + manager_cost
539
+ ) * self.labour_hours_per_shift
540
+
541
+ power_cost_per_kg = self.kwh_rate * self.kwh_per_kg_bio
542
+ water_cost_per_kg = (
543
+ self.water_cost_per_1000l / 1000.0
544
+ ) * self.water_liters_consumed_per_kg_bio
545
+ total_variable_consumable_cost_per_kg = (
546
+ self.consumables_per_kg_bio_rate + power_cost_per_kg + water_cost_per_kg
547
+ )
548
+ self.variable_cost_per_shift = (
549
+ total_variable_consumable_cost_per_kg * self.kg_processed_per_shift
550
+ )
551
+
552
+ self.overhead_cost_per_shift = (
553
+ self.fixed_overhead_per_week / self.shifts_per_week
554
+ if self.shifts_per_week > 0
555
+ else 0.0
556
+ )
557
+
558
+ shift_cogs_before_output_specific = (
559
+ self.labour_cost_per_shift
560
+ + self.variable_cost_per_shift
561
+ + self.overhead_cost_per_shift
562
+ )
563
+ shift_output_specific_cogs = (
564
+ self.consumables_per_kg_output * self.saleable_kg_per_shift
565
+ )
566
+
567
+ self.internal_cogs_per_shift = (
568
+ shift_cogs_before_output_specific + shift_output_specific_cogs
569
+ )
570
+ self.internal_cogs_per_kg_bio = (
571
+ self.internal_cogs_per_shift / self.kg_processed_per_shift
572
+ if self.kg_processed_per_shift > 0
573
+ else 0.0
574
+ )
575
+ self.internal_cogs_per_day = self.internal_cogs_per_shift * self.shifts_per_day
576
+ self.internal_cogs_per_week = (
577
+ self.internal_cogs_per_shift * self.shifts_per_week
578
+ )
579
+ self.internal_cogs_per_kg_output = (
580
+ (self.internal_cogs_per_kg_bio * self.biomass_kg_per_saleable_kg)
581
+ if self.biomass_kg_per_saleable_kg != 0
582
+ else 0.0
583
+ )
584
+
585
+ def _calc_gross_revenue(self):
586
+ self.gross_rev_per_kg_bio = (
587
+ self.saleable_kg_per_kg_bio * self.wholesale_cbx_price
588
+ )
589
+ self.gross_rev_per_shift = (
590
+ self.gross_rev_per_kg_bio * self.kg_processed_per_shift
591
+ )
592
+ self.gross_rev_per_day = self.gross_rev_per_shift * self.shifts_per_day
593
+ self.gross_rev_per_week = self.gross_rev_per_shift * self.shifts_per_week
594
+
595
+ def _calc_net_revenue(self):
596
+ self.net_rev_per_kg_bio = (
597
+ self.gross_rev_per_kg_bio - self.internal_cogs_per_kg_bio - self.bio_cost
598
+ )
599
+ self.net_rev_per_shift = self.net_rev_per_kg_bio * self.kg_processed_per_shift
600
+ self.net_rev_per_day = self.net_rev_per_shift * self.shifts_per_day
601
+ self.net_rev_per_week = self.net_rev_per_shift * self.shifts_per_week
602
+ self.net_rev_per_kg_output = (
603
+ (self.biomass_kg_per_saleable_kg * self.net_rev_per_kg_bio)
604
+ if self.biomass_kg_per_saleable_kg != 0
605
+ else 0.0
606
+ )
607
+
608
+ def _calc_biomass_cost(self):
609
+ self.biomass_cost_per_shift = self.kg_processed_per_shift * self.bio_cost
610
+ self.biomass_cost_per_day = self.biomass_cost_per_shift * self.shifts_per_day
611
+ self.biomass_cost_per_week = self.biomass_cost_per_shift * self.shifts_per_week
612
+
613
+ def _calc_saleable_kg(self):
614
+ if self.wholesale_cbx_pct == 0:
615
+ self.saleable_kg_per_kg_bio = 0.0
616
+ else:
617
+ self.saleable_kg_per_kg_bio = (
618
+ (self.bio_cbx_pct / 100.0)
619
+ * (self.finished_product_yield_pct / 100.0)
620
+ / (self.wholesale_cbx_pct / 100.0)
621
+ )
622
+ self.saleable_kg_per_shift = (
623
+ self.saleable_kg_per_kg_bio * self.kg_processed_per_shift
624
+ )
625
+ self.saleable_kg_per_day = self.saleable_kg_per_shift * self.shifts_per_day
626
+ self.saleable_kg_per_week = self.saleable_kg_per_shift * self.shifts_per_week
627
+ self.biomass_kg_per_saleable_kg = (
628
+ 1 / self.saleable_kg_per_kg_bio if self.saleable_kg_per_kg_bio > 0 else 0.0
629
+ )
630
+ self.biomass_cost_per_kg_output = (
631
+ self.biomass_kg_per_saleable_kg * self.bio_cost
632
+ )
633
+
634
+ def _update_tables_data(self):
635
+ money_data_dict = {
636
+ " ": ["Biomass cost", "Processing cost", "Gross Revenue", "Net Revenue"],
637
+ "$/kg Biomass": [
638
+ self.bio_cost,
639
+ self.internal_cogs_per_kg_bio,
640
+ self.gross_rev_per_kg_bio,
641
+ self.net_rev_per_kg_bio,
642
+ ],
643
+ "$/kg Output": [
644
+ self.biomass_cost_per_kg_output,
645
+ self.internal_cogs_per_kg_output,
646
+ self.wholesale_cbx_price,
647
+ self.net_rev_per_kg_output,
648
+ ],
649
+ "Per Shift": [
650
+ self.biomass_cost_per_shift,
651
+ self.internal_cogs_per_shift,
652
+ self.gross_rev_per_shift,
653
+ self.net_rev_per_shift,
654
+ ],
655
+ "Per Day": [
656
+ self.biomass_cost_per_day,
657
+ self.internal_cogs_per_day,
658
+ self.gross_rev_per_day,
659
+ self.net_rev_per_day,
660
+ ],
661
+ "Per Week": [
662
+ self.biomass_cost_per_week,
663
+ self.internal_cogs_per_week,
664
+ self.gross_rev_per_week,
665
+ self.net_rev_per_week,
666
+ ],
667
+ }
668
+ self.money_data_df = pd.DataFrame(money_data_dict)
669
+ if hasattr(self, "money_table"):
670
+ self.money_table.value = self.money_data_df
671
+
672
+ profit_data_dict = {
673
+ "Metric": ["Operating Profit", "Resin Spread"],
674
+ "Value": [
675
+ f"{self.operating_profit_pct * 100.0:.2f}%",
676
+ f"{self.resin_spread_pct * 100.0:.2f}%",
677
+ ],
678
+ }
679
+ self.profit_data_df = pd.DataFrame(profit_data_dict)
680
+ if hasattr(self, "profit_table"):
681
+ self.profit_table.value = self.profit_data_df
682
+
683
+ processing_values_formatted = [
684
+ f"{self.kg_processed_per_shift:,.0f}",
685
+ f"${self.labour_cost_per_shift:,.2f}",
686
+ f"${self.variable_cost_per_shift:,.2f}",
687
+ f"${self.overhead_cost_per_shift:,.2f}",
688
+ ]
689
+ processing_data_dict = {
690
+ "Metric (Per Shift)": [
691
+ "Kilograms Extracted",
692
+ "Labour Cost",
693
+ "Variable Cost",
694
+ "Overhead",
695
+ ],
696
+ "Value": processing_values_formatted,
697
+ }
698
+ self.processing_data_df = pd.DataFrame(processing_data_dict)
699
+ if hasattr(self, "processing_table"):
700
+ self.processing_table.value = self.processing_data_df
701
+
702
+ def _get_money_formatters(self):
703
+ return {
704
+ "$/kg Biomass": get_formatter("$%.02f"),
705
+ "$/kg Output": get_formatter("$%.02f"),
706
+ "Per Shift": get_formatter("$%.02f"),
707
+ "Per Day": get_formatter("$%.02f"),
708
+ "Per Week": get_formatter("$%.02f"),
709
+ }
710
+
711
+ def view(self):
712
+ input_col_max_width = 400
713
+ col1 = pn.Column(
714
+ "### Extraction",
715
+ self.kg_processed_per_hour_slider,
716
+ self.finished_product_yield_pct_slider,
717
+ sizing_mode="stretch_width",
718
+ max_width=input_col_max_width,
719
+ )
720
+ col2 = pn.Column(
721
+ pn.pane.Markdown("### Biomass parameters"),
722
+ self.bio_cbx_pct_slider,
723
+ self.bio_cost_slider,
724
+ sizing_mode="stretch_width",
725
+ max_width=input_col_max_width,
726
+ )
727
+ col3 = pn.Column(
728
+ pn.pane.Markdown("### Consumable rates"),
729
+ self.kwh_rate_slider,
730
+ self.water_cost_per_1000l_slider,
731
+ self.consumables_per_kg_bio_rate_slider,
732
+ sizing_mode="stretch_width",
733
+ max_width=input_col_max_width,
734
+ )
735
+ col4 = pn.Column(
736
+ pn.pane.Markdown("### Wholesale details"),
737
+ self.wholesale_cbx_price_slider,
738
+ self.wholesale_cbx_pct_slider,
739
+ sizing_mode="stretch_width",
740
+ max_width=input_col_max_width,
741
+ )
742
+ col5 = pn.Column(
743
+ pn.pane.Markdown("### Variable costs"),
744
+ self.kwh_per_kg_bio_slider,
745
+ self.water_liters_consumed_per_kg_bio_slider,
746
+ self.consumables_per_kg_output_slider,
747
+ sizing_mode="stretch_width",
748
+ max_width=input_col_max_width,
749
+ )
750
+ col6 = pn.Column(
751
+ pn.pane.Markdown("### Compliance"),
752
+ self.batch_test_cost_slider,
753
+ pn.pane.Markdown("### Overhead"),
754
+ self.fixed_overhead_per_week_slider,
755
+ sizing_mode="stretch_width",
756
+ max_width=input_col_max_width,
757
+ )
758
+ col8 = pn.Column(
759
+ pn.pane.Markdown("### Worker Details"),
760
+ self.workers_per_shift_slider,
761
+ self.worker_hourly_rate_slider,
762
+ self.managers_per_shift_slider,
763
+ self.manager_hourly_rate_slider,
764
+ sizing_mode="stretch_width",
765
+ max_width=input_col_max_width,
766
+ )
767
+ col9 = pn.Column(
768
+ pn.pane.Markdown("### Shift details"),
769
+ self.labour_hours_per_shift_slider,
770
+ self.processing_hours_per_shift_slider,
771
+ self.shifts_per_day_slider,
772
+ self.shifts_per_week_slider,
773
+ sizing_mode="stretch_width",
774
+ max_width=input_col_max_width,
775
+ )
776
+
777
+ input_grid = pn.FlexBox(
778
+ col1, col2, col3, col4, col5, col8, col9, col6, align_content="normal"
779
+ )
780
+
781
+ money_table_display = pn.Column(
782
+ pn.pane.Markdown("### Financial Summary", styles={"text-align": "center"}),
783
+ self.money_table,
784
+ sizing_mode="stretch_width",
785
+ max_width=700,
786
+ )
787
+
788
+ profit_table_display = pn.Column(
789
+ pn.pane.Markdown("### Profitability", styles={"text-align": "center"}),
790
+ self.profit_table,
791
+ sizing_mode="stretch_width",
792
+ max_width=input_col_max_width,
793
+ )
794
+
795
+ processing_table_display = pn.Column(
796
+ pn.pane.Markdown("### Processing Summary", styles={"text-align": "center"}),
797
+ self.processing_table,
798
+ sizing_mode="stretch_width",
799
+ max_width=input_col_max_width,
800
+ )
801
+
802
+ profit_weekly = pn.indicators.Number(
803
+ name="Weekly Profit",
804
+ value=self.net_rev_per_week,
805
+ format=f"${self.net_rev_per_week / 1000:.0f} k",
806
+ default_color="green",
807
+ align="center",
808
+ )
809
+
810
+ profit_pct = pn.indicators.Number(
811
+ name="Operating Profit",
812
+ value=self.operating_profit_pct,
813
+ format=f"{self.operating_profit_pct * 100.0:.2f}%",
814
+ default_color="green",
815
+ align="center",
816
+ )
817
+
818
+ table_grid = pn.FlexBox(
819
+ profit_weekly,
820
+ profit_pct,
821
+ processing_table_display,
822
+ profit_table_display,
823
+ money_table_display,
824
+ align_content="normal",
825
+ )
826
+
827
+ main_layout = pn.Column(
828
+ input_grid,
829
+ pn.layout.Divider(margin=(10, 0)),
830
+ table_grid,
831
+ styles={"margin": "0px 10px"},
832
+ )
833
+
834
+ return main_layout
835
+
836
+
837
+ estimator_app = CannabinoidEstimator()
838
+ # To run in a Panel server:
839
+ # pn.config.raw_css = custom_themes.get_base_css(custom_themes.DARK_THEME_VARS)
840
+ estimator_app.view().servable(title="CBx Revenue Estimator")
841
+
842
+ if __name__ == "__main__":
843
+ pn.serve(
844
+ estimator_app.view(),
845
+ title="CBx Revenue Estimator (Panel)",
846
+ show=True,
847
+ port=5007,
848
+ )