-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
498 lines (403 loc) · 23.6 KB
/
Copy pathapp.py
File metadata and controls
498 lines (403 loc) · 23.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
from io import BytesIO
import pandas as pd
import streamlit as st
from src import (
validate_input_data,
LogisticsSolver,
visualize_routes
)
# Конфигурация страницы
st.set_page_config(
page_title="Logistics Solver",
page_icon="🚛",
initial_sidebar_state="expanded"
)
def main():
st.title("🚛 Оптимизация логистических маршрутов")
st.markdown("""
Данное приложение решает задачу маршрутизации транспорта (Vehicle Routing Problem)
для доставки товаров трёх категорий (П, М, Н) со склада в магазины.
""")
# Боковая панель с настройками
with st.sidebar:
st.header("⚙️ Параметры задачи")
num_vehicles = st.number_input(
"Количество машин",
min_value=1,
max_value=20,
value=6,
help="Общее количество доступных транспортных средств"
)
capacity = st.number_input(
"Вместимость машины (коробки)",
min_value=10,
max_value=500,
value=120,
help="Максимальная вместимость одного транспортного средства"
)
use_3opt = st.checkbox(
"Использовать 3-opt оптимизацию",
value=True,
help="Более глубокая оптимизация маршрутов (может занять больше времени)"
)
st.markdown("---")
st.markdown("### 📊 Легенда товаров")
st.markdown("""
- **П** - Продукты питания
- **М** - Мясо/молоко (холод)
- **Н** - Непродовольственные товары
""")
# Основная область
tab1, tab2, tab3 = st.tabs(["📥 Ввод данных", "🗺️ Результаты", "ℹ️ Справка"])
with tab1:
st.header("Ввод данных")
# Инициализация начального значения
if 'num_stores' not in st.session_state:
st.session_state['num_stores'] = 30
# Callback для обновления при изменении виджета
def update_num_stores():
# Перед ресайзом переносим текущие правки из widget state в базовый DataFrame,
# чтобы они не потерялись после очистки coords_editor/stores_editor.
for df_key, editor_key in (('coords_df', 'coords_editor'),
('stores_df', 'stores_editor')):
editor_state = st.session_state.get(editor_key)
if editor_state and df_key in st.session_state:
df = st.session_state[df_key].copy()
for row_idx, row_edits in editor_state.get('edited_rows', {}).items():
for col, val in row_edits.items():
df.at[int(row_idx), col] = val
st.session_state[df_key] = df
st.session_state['num_stores'] = st.session_state['num_stores_widget']
st.session_state['need_recreate'] = True
for k in ('coords_editor', 'stores_editor'):
st.session_state.pop(k, None)
# Количество магазинов
st.number_input(
"Количество магазинов",
min_value=1,
max_value=100,
value=st.session_state['num_stores'],
help="Укажите количество магазинов",
key='num_stores_widget',
on_change=update_num_stores
)
num_stores = st.session_state['num_stores']
st.markdown("---")
col1, col2 = st.columns(2)
with col1:
st.subheader("1. Координаты точек")
st.markdown("Склад (точка 0) и магазины")
# Создаём DataFrame для координат
need_recreate = st.session_state.get('need_recreate', False)
if 'coords_df' not in st.session_state or need_recreate:
# Сохраняем старые данные если они есть
old_coords = st.session_state.get('coords_df', None)
# Базовые данные (30 магазинов)
base_x = [6] + [19, 25, 28, 27, 20, 18, 16, 13, 9, 11, 4, 6, 2, 12, 4, 8, 2, 8, 13, 12, 15, 16, 18, 20,
23, 23, 27, 20, 24, 28]
base_y = [8] + [9, 6, 4, 2, 5, 2, 7, 3, 2, 7, 4, 7, 8, 9, 11, 12, 14, 15, 12, 15, 14, 17, 12, 16, 17,
14, 17, 15, 10, 8]
# Если есть старые данные, используем их
if old_coords is not None and len(old_coords) > 0:
base_x = old_coords['X'].tolist()
base_y = old_coords['Y'].tolist()
# Если нужно больше магазинов, добавляем значения по умолчанию
while len(base_x) < num_stores + 1:
base_x.append(10)
base_y.append(10)
coords_data = {
"№": ["Склад"] + [str(i) for i in range(1, num_stores + 1)], # Всё строки
"X": base_x[:num_stores + 1],
"Y": base_y[:num_stores + 1]
}
st.session_state.coords_df = pd.DataFrame(coords_data)
# Редактируемая таблица
coords_df = st.data_editor(
st.session_state.coords_df,
key="coords_editor",
use_container_width=True,
num_rows="fixed",
disabled=["№"],
column_config={
"№": st.column_config.TextColumn("№", width="small", disabled=True),
"X": st.column_config.NumberColumn("X", min_value=0, max_value=100, step=1),
"Y": st.column_config.NumberColumn("Y", min_value=0, max_value=100, step=1),
},
hide_index=True,
height=400
)
with col2:
st.subheader("2. Заказы магазинов")
st.markdown("Укажите количество коробок каждого типа товара")
# Создаём DataFrame для заказов
if 'stores_df' not in st.session_state or need_recreate:
# Сохраняем старые данные если они есть
old_stores = st.session_state.get('stores_df', None)
# Базовые данные (30 магазинов)
base_p = [20, 0, 10, 0, 25, 10, 6, 22, 14, 4, 9, 25, 10, 15, 8, 0, 15, 20, 6, 24, 8, 11, 5, 16, 0, 20,
0, 10, 15, 12]
base_m = [0, 4, 11, 17, 0, 5, 4, 0, 6, 3, 5, 0, 4, 6, 0, 10, 5, 0, 8, 0, 5, 0, 5, 0, 0, 20, 0, 2, 12, 8]
base_n = [6, 12, 15, 8, 5, 18, 10, 0, 6, 8, 5, 11, 6, 10, 0, 0, 12, 5, 0, 8, 0, 0, 22, 0, 8, 20, 13, 0,
12, 20]
# Если есть старые данные, используем их
if old_stores is not None and len(old_stores) > 0:
base_p = old_stores['П'].tolist()
base_m = old_stores['М'].tolist()
base_n = old_stores['Н'].tolist()
# Если нужно больше магазинов, добавляем значения по умолчанию
while len(base_p) < num_stores:
base_p.append(10)
base_m.append(0)
base_n.append(5)
stores_data = {
"№ магазина": list(range(1, num_stores + 1)),
"П": base_p[:num_stores],
"М": base_m[:num_stores],
"Н": base_n[:num_stores]
}
st.session_state.stores_df = pd.DataFrame(stores_data)
# Сбрасываем флаг
if need_recreate:
st.session_state['need_recreate'] = False
# Редактируемая таблица
stores_df = st.data_editor(
st.session_state.stores_df,
key="stores_editor",
use_container_width=True,
num_rows="fixed",
disabled=["№ магазина"],
column_config={
"№ магазина": st.column_config.NumberColumn("№ магазина", disabled=True),
"П": st.column_config.NumberColumn("П", min_value=0, max_value=1000, step=1, help="Продукты"),
"М": st.column_config.NumberColumn("М", min_value=0, max_value=1000, step=1, help="Мясо/молоко"),
"Н": st.column_config.NumberColumn("Н", min_value=0, max_value=1000, step=1,
help="Непродовольственные"),
},
hide_index=True,
height=400
)
st.markdown("---")
# Экспорт данных
with st.expander("📤 Экспорт данных в Excel"):
col_exp1, col_exp2 = st.columns(2)
with col_exp1:
# Создаём Excel файл для координат
coords_buffer = BytesIO()
with pd.ExcelWriter(coords_buffer, engine='openpyxl') as writer:
coords_df.to_excel(writer, index=False, sheet_name='Координаты')
coords_buffer.seek(0)
st.download_button(
"⬇️ Скачать координаты",
coords_buffer,
"coordinates.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
use_container_width=True
)
with col_exp2:
# Создаём Excel файл для заказов
stores_buffer = BytesIO()
with pd.ExcelWriter(stores_buffer, engine='openpyxl') as writer:
stores_df.to_excel(writer, index=False, sheet_name='Заказы')
stores_buffer.seek(0)
st.download_button(
"⬇️ Скачать заказы",
stores_buffer,
"stores.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
use_container_width=True
)
st.markdown("---")
# Кнопки действий
col_btn1, col_btn2 = st.columns([3, 1])
with col_btn1:
solve_button = st.button("🚀 Решить задачу", type="primary", use_container_width=True)
with col_btn2:
if st.button("🔄 Сбросить", use_container_width=True):
if 'coords_df' in st.session_state:
del st.session_state.coords_df
if 'stores_df' in st.session_state:
del st.session_state.stores_df
st.rerun()
if solve_button:
with st.spinner("Обработка данных и поиск оптимальных маршрутов..."):
try:
# Парсинг координат из таблицы
points = []
for _, row in coords_df.iterrows():
points.append((int(row['X']), int(row['Y'])))
# Парсинг данных магазинов из таблицы
stores_inventory = {}
for _, row in stores_df.iterrows():
store_id = int(row['№ магазина'])
p = int(row['П'])
m = int(row['М'])
n = int(row['Н'])
stores_inventory[store_id] = {"П": p, "М": m, "Н": n}
# Валидация
validation_result = validate_input_data(
points, stores_inventory, num_vehicles, capacity
)
if not validation_result["valid"]:
st.error(f"❌ Ошибка валидации: {validation_result['error']}")
return
# Решение задачи
solver = LogisticsSolver(
points=points,
stores_inventory=stores_inventory,
num_vehicles=num_vehicles,
capacity=capacity,
use_3opt=use_3opt
)
routes = solver.solve()
# Сохранение результатов в session_state
st.session_state['routes'] = routes
st.session_state['points'] = points
st.session_state['solver'] = solver
st.success("✅ Задача успешно решена! Перейдите на вкладку 'Результаты'")
except Exception as e:
st.error(f"❌ Ошибка при решении задачи: {str(e)}")
import traceback
with st.expander("Подробности ошибки"):
st.code(traceback.format_exc())
with tab2:
st.header("Результаты оптимизации")
if 'routes' not in st.session_state:
st.info("👈 Сначала введите данные и решите задачу на вкладке 'Ввод данных'")
else:
routes = st.session_state['routes']
points = st.session_state['points']
solver = st.session_state['solver']
# Статистика
st.subheader("📊 Общая статистика")
col1, col2, col3, col4 = st.columns(4)
total_distance = sum(solver.calculate_route_distance(r) for r in routes)
total_cargo = sum(sum(p.get(k, 0) for k in ["П", "М", "Н"]) for r in routes for p in r)
with col1:
st.metric("Всего маршрутов", len(routes))
with col2:
st.metric("Общая дистанция", f"{total_distance} км")
with col3:
st.metric("Всего коробок", total_cargo)
with col4:
avg_load = (total_cargo / (len(routes) * capacity) * 100) if routes else 0
st.metric("Ср. загрузка", f"{avg_load:.1f}%")
st.markdown("---")
# Детализация по маршрутам
st.subheader("🗺️ Детализация маршрутов")
for i, route in enumerate(routes, 1):
with st.expander(f"Маршрут {i}", expanded=(i == 1)):
route_distance = solver.calculate_route_distance(route)
route_cargo = sum(p.get("П", 0) + p.get("М", 0) + p.get("Н", 0) for p in route)
st.markdown(f"**Параметры:** {route_cargo} коробок, {route_distance} км")
# Таблица точек маршрута
route_data = []
for point in route:
route_data.append({
"№ магазина": "Склад" if point["id"] == 0 else str(point["id"]),
"П": str(point.get("П", 0) or ""),
"М": str(point.get("М", 0) or ""),
"Н": str(point.get("Н", 0) or "")
})
df_route = pd.DataFrame(route_data)
st.dataframe(df_route, use_container_width=True, hide_index=True)
st.markdown("---")
# Визуализация
st.subheader("📍 Визуализация маршрутов")
# Общая визуализация
with st.expander("🗺️ Все маршруты на одной карте", expanded=True):
fig = visualize_routes(routes, points)
st.pyplot(fig)
# Визуализация по каждому маршруту
st.markdown("### 🚛 Маршруты по отдельности")
# Создаём колонки для маршрутов (по 2 в ряд)
for i in range(0, len(routes), 2):
cols = st.columns(2)
for j, col in enumerate(cols):
route_idx = i + j
if route_idx < len(routes):
with col:
route = routes[route_idx]
route_num = route_idx + 1
# Параметры маршрута
route_distance = solver.calculate_route_distance(route)
route_cargo = sum(p.get("П", 0) + p.get("М", 0) + p.get("Н", 0) for p in route)
st.markdown(f"**Маршрут {route_num}**")
st.caption(f"📦 {route_cargo} коробок • 📏 {route_distance} км")
# Визуализация одного маршрута
fig_single = visualize_routes([route], points, figsize=(7, 6))
st.pyplot(fig_single)
# Экспорт в Excel
st.markdown("---")
st.subheader("💾 Экспорт результатов")
excel_buffer = solver.export_to_excel(routes)
st.download_button(
label="📥 Скачать результаты в Excel",
data=excel_buffer,
file_name="logistics_routes.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
use_container_width=True
)
with tab3:
st.header("ℹ️ Справка")
st.markdown("""
### О приложении
Данное приложение решает задачу маршрутизации транспортных средств (Vehicle Routing Problem)
для сбора товаров из магазинов и доставки их на склад с учётом ограничений по типам грузов.
### Описание задачи
**Дано:**
- Склад (начальная и конечная точка всех маршрутов)
- Магазины с товарами трёх типов: Н (непродовольственные), П (продукты), М (мясо/молоко)
- $K$ машин, одновременно выезжающих на маршруты
- Вместимость каждой машины: **строго не более $W$ коробок**
**Особенности:**
- Машина может забирать товары **любых категорий и в любом количестве**
- Можно забрать все товары из магазина или только часть
- Машина может посещать несколько магазинов: $M_1 \\rightarrow M_2 \\rightarrow M_3 \\rightarrow \\cdots \\rightarrow$ Склад
**Ключевое ограничение:**
- ⚠️ Товары **П и М нельзя перевозить вместе** в одной машине (разное оборудование)
- ✅ Допустимые комбинации: только П, только М, только Н, П+Н, М+Н
- ❌ Недопустимо: П+М, П+М+Н
**Цель:**
Составить маршруты для $K$ машин так, чтобы **все товары из всех магазинов**
были доставлены на склад.
### Особенности задачи
#### Три типа товаров
- **Н** - Непродовольственные товары (гибкие, перевозятся с любыми)
- **П** - Продукты питания (обычная температура)
- **М** - Мясо/молоко (требуют холодильное оборудование)
#### Дробление заказов
Если в магазине много товара, он может быть разделён между несколькими машинами.
Например, магазин с (П=50, М=0, Н=30):
- Машина 1 забирает: П=50, Н=20
- Машина 2 забирает: Н=10
#### Расстояния
Рассчитываются по **манхэттенской метрике**: $|x_1-x_2| + |y_1-y_2|$
(движение только по горизонтали и вертикали, как в городе с кварталами)
### Алгоритм решения
1. **Разделение задач** по типам грузов с учётом ограничений совместимости
2. **Упаковка** товаров в машины (bin packing) с возможностью дробления заказов
3. **Построение маршрутов** методом ближайшего соседа (Nearest Neighbor)
4. **Оптимизация** маршрутов с помощью 2-opt и опционально 3-opt алгоритмов
### Формат входных данных
#### Координаты точек
Таблица с координатами точек:
- **Склад** (№ = "Склад") - начальная и конечная точка всех маршрутов
- **Магазины** (№ = 1, 2, 3...) - точки сбора товаров
- **X, Y** - координаты точек на плоскости (можно редактировать)
#### Заказы магазинов
Таблица с заказами:
- **№ магазина** - номер магазина (автоматически)
- **Н** - количество коробок непродовольственных товаров
- **П** - количество коробок продуктов питания
- **М** - количество коробок мясо/молоко (холод)
Все ячейки редактируемые, кроме номеров магазинов.
### Результаты
Приложение выдаёт:
- Оптимальные маршруты для каждой машины
- Общую статистику (расстояние, загрузка)
- Визуализацию маршрутов на карте с сеткой
- Детализацию по каждому маршруту
- Возможность экспорта в Excel
""")
if __name__ == "__main__":
main()