-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoptimization_page.py
More file actions
2862 lines (2584 loc) · 177 KB
/
Copy pathoptimization_page.py
File metadata and controls
2862 lines (2584 loc) · 177 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
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
optimization_page.py -- окрема сторінка "Оптимізація" (винесена з
analysis_page.py, де раніше була вкладкою всередині "Аналіз" -- тепер
власний пункт навбару, праворуч від "Аналіз"). Дві вкладки всередині:
"Координати" -- горизонтальний обхід заборонних зон.
"Висота" -- для кожної точки маршруту: рельєф у ній + цільова
відносна висота зони (контрольована/окупована територія, "Тип
маршруту" в Конфігурації), де вона фактично лежить (перевірка через
державний кордон, country_borders.py). Ребро, де відбувається
перетин кордону, отримує висоту переходу на обидві кінцеві точки.
Після цього -- ітеративна перевірка кліренсу рельєфу уздовж кожного
відрізка (крок 50м): якщо десь порушено, піднімається ближній кінець
ребра чи вставляється нова точка, залежно від того, де саме
найгірший кліренс.
"""
from __future__ import annotations
import threading
import math
import time
import dataclasses
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from geo import haversine_m, lonlat_to_pixel
from map_view import bind_pan, render_viewport
import populated_areas
import restricted_zones
import route_optimizer
import altitude_optimizer
import route_types
import occupied_layer
import country_borders
import aircraft_profiles
from waypoints import Waypoint, write_waypoints
from srtm import SRTMError
import theme
import i18n
from mission_page import MISSION_THEME_DARK, MISSION_THEME_LIGHT
from elevation_view import draw_altitude_before_after
from angle_view import draw_angle_profile
class OptimizationPageMixin:
def _build_optimization_page(self, content, pad):
"""Сторінка "Оптимізація" -- notebook з двома вкладками
("Координати" -- вже реалізовано, "Висота" -- заглушка,
реалізація окремим кроком)."""
page_optimization = ttk.Frame(content)
page_optimization.grid(row=0, column=0, sticky="nsew")
self.pages["optimization"] = page_optimization
self.opt_notebook = ttk.Notebook(page_optimization)
self.opt_notebook.pack(fill="both", expand=True, **pad)
self._optimization_outer_canvases = [] # для перефарбовки при зміні теми
self._optimization_report_texts = []
self._optimization_vbars = []
initial_viewport_h = getattr(self, "_viewport_h", None) or 700
def make_scroll_tab(tab_title_key: str):
"""Той самий паттерн, що й у analysis_page.py -- один
вертикальний скрол на всю вкладку. Дубльовано тут навмисно
(невеликий, суто layout-допоміжний код, не "значення", яке
мало б бути єдиним джерелом істини на кшталт кольорів/
шрифтів у theme.py)."""
tab = ttk.Frame(self.opt_notebook)
self.opt_notebook.add(tab, text=i18n.t(tab_title_key))
self._retranslate_callbacks.append(
lambda _t=tab, _k=tab_title_key: self.opt_notebook.tab(_t, text=i18n.t(_k))
)
outer = tk.Canvas(tab, highlightthickness=0, bg=self.palette["bg"])
self._optimization_outer_canvases.append(outer)
vbar = ttk.Scrollbar(tab, orient="vertical", command=outer.yview)
self._optimization_vbars.append(vbar)
outer.configure(yscrollcommand=vbar.set)
outer.pack(side="left", fill="both", expand=True)
vbar.pack(side="right", fill="y")
inner = ttk.Frame(outer)
inner_id = outer.create_window((0, 0), window=inner, anchor="nw")
def _on_inner_configure(event, _outer=outer):
_outer.configure(scrollregion=_outer.bbox("all"))
def _on_outer_configure(event, _outer=outer, _id=inner_id):
_outer.itemconfigure(_id, width=event.width)
inner.bind("<Configure>", _on_inner_configure)
outer.bind("<Configure>", _on_outer_configure)
def _on_mousewheel(event, _outer=outer):
_outer.yview_scroll(int(-1 * (event.delta / 120)), "units")
outer.bind_all("<MouseWheel>", _on_mousewheel, add="+")
return tab, inner
def make_plain_text(parent, height: int):
"""Той самий паттерн, що в analysis_page.py -- звичайний
tk.Text без власної смуги прокрутки."""
c = MISSION_THEME_DARK if self._is_dark_theme() else MISSION_THEME_LIGHT
widget = tk.Text(
parent, wrap="word", font=("Consolas", 9),
height=height, width=1, relief="solid", borderwidth=1,
bg=c["table_bg"], fg=c["table_fg"], insertbackground=c["table_fg"],
)
theme.make_text_readonly(widget)
self._optimization_report_texts.append(widget)
return widget
# --- «Координати» = обхід ВСІХ проблемних ребер маршруту одним розрахунком ---
optimize_tab, optimize_inner = make_scroll_tab("tab_optimization_coordinates")
# Вибір профілю літака -- заповнює поля НИЖЧЕ (крейсерська
# швидкість/витрата/крен), ЛИШЕ для цього розрахунку. НЕ змінює
# "поточний" профіль у "Конфігурації" -- вибір тут суто локальна
# зручність, без побічного ефекту на решту програми. Заодно
# користувач, який ніколи не заходив у "Конфігурація", тут
# взагалі дізнається, що профілі існують.
opt_profile_row = ttk.Frame(optimize_inner)
opt_profile_row.pack(fill="x", pady=(0, 6))
self._reg_i18n(ttk.Label(opt_profile_row), "text", "lbl_aircraft_profile").pack(side="left", padx=(0, 4))
self.opt_profile_var = tk.StringVar(value="")
self.opt_profile_box = ttk.Combobox(
opt_profile_row, textvariable=self.opt_profile_var, state="readonly", width=22,
)
self.opt_profile_box.pack(side="left")
self.opt_profile_box.bind("<<ComboboxSelected>>", self._on_optimize_profile_selected)
# список профілів читається заново ПЕРЕД кожним відкриттям (не
# тільки один раз при побудові сторінки) -- профіль міг бути
# доданий у "Конфігурації" вже ПІСЛЯ того, як "Аналіз" збудувався
self.opt_profile_box.bind("<Button-1>", self._refresh_optimize_profile_list)
opt_controls = ttk.Frame(optimize_inner)
opt_controls.pack(fill="x", pady=(0, 4))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_tank_capacity").pack(side="left", padx=(0, 4))
self.opt_tank_capacity_var = tk.StringVar(value="80")
ttk.Entry(opt_controls, textvariable=self.opt_tank_capacity_var, width=6).pack(side="left", padx=(0, 2))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_liters").pack(side="left", padx=(0, 12))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_cruise_consumption").pack(side="left", padx=(0, 4))
self.opt_cruise_consumption_var = tk.StringVar(value="3.0")
ttk.Entry(opt_controls, textvariable=self.opt_cruise_consumption_var, width=6).pack(side="left", padx=(0, 2))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_liters_per_hour").pack(side="left", padx=(0, 12))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_cruise_speed").pack(side="left", padx=(0, 4))
self.opt_cruise_speed_var = tk.StringVar(value="90")
ttk.Entry(opt_controls, textvariable=self.opt_cruise_speed_var, width=6).pack(side="left", padx=(0, 2))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_kmh").pack(side="left", padx=(0, 12))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_roll_limit").pack(side="left", padx=(0, 4))
self.opt_roll_limit_var = tk.StringVar(value="25")
ttk.Entry(opt_controls, textvariable=self.opt_roll_limit_var, width=6).pack(side="left", padx=(0, 2))
self._reg_i18n(ttk.Label(opt_controls), "text", "lbl_deg").pack(side="left")
opt_controls2 = ttk.Frame(optimize_inner)
opt_controls2.pack(fill="x", pady=(0, 8))
self._reg_i18n(ttk.Label(opt_controls2), "text", "lbl_exclude_landing_legs").pack(side="left", padx=(0, 4))
self.opt_exclude_legs_var = tk.StringVar(value="2")
ttk.Entry(opt_controls2, textvariable=self.opt_exclude_legs_var, width=4).pack(side="left", padx=(0, 12))
self.optimize_route_btn = ttk.Button(opt_controls2, command=self._run_route_optimization)
self._reg_i18n(self.optimize_route_btn, "text", "btn_optimize_route")
self.optimize_route_btn.pack(side="left")
self.save_optimized_btn = ttk.Button(
opt_controls2, command=self._save_optimized_mission, state="disabled",
)
self._reg_i18n(self.save_optimized_btn, "text", "btn_save_optimized_mission")
self.save_optimized_btn.pack(side="left", padx=(6, 0))
# Якщо користувач ЗМІНЮЄ будь-яке з полів після успішної
# оптимізації -- це сигнал "хочу спробувати з іншими
# параметрами", тож "Оптимізувати" знову розблоковується.
# Профіль (opt_profile_var) теж входить -- вибір іншого
# профілю зі списку так само підставляє нові значення полів.
def _on_optimize_settings_changed(*_args):
if self.optimize_route_btn.cget("state") == "disabled":
self.optimize_route_btn.configure(state="normal")
for var in (
self.opt_tank_capacity_var, self.opt_cruise_consumption_var,
self.opt_cruise_speed_var, self.opt_roll_limit_var,
self.opt_exclude_legs_var, self.opt_profile_var,
):
var.trace_add("write", _on_optimize_settings_changed)
# підтягуємо крейсерську швидкість/витрату/крен з ПОТОЧНОГО
# профілю літака (Конфігурація), якщо такий є -- користувачу не
# треба вводити ці значення вручну щоразу, вони вже задані один
# раз на профіль. Поля лишаються звичайними editable Entry --
# це лише ЗРУЧНА ПІДКАЗКА за замовчуванням, не жорстка
# прив'язка, для одноразового тесту з іншими цифрами можна
# просто перезаписати вручну.
self._refresh_optimize_profile_list()
self._prefill_optimization_from_profile()
self.optimize_status_var = tk.StringVar(value="")
ttk.Label(optimize_inner, textvariable=self.optimize_status_var, foreground="#888").pack(
anchor="w", pady=(0, 4)
)
optimize_map_box = ttk.LabelFrame(optimize_inner, height=initial_viewport_h)
self._reg_i18n(optimize_map_box, "text", "box_optimization_map")
optimize_map_box.pack(fill="x", pady=(0, 8))
optimize_map_box.pack_propagate(False)
self._optimize_map_box = optimize_map_box
self.optimize_map_canvas = tk.Canvas(optimize_map_box, bg=self._map_placeholder_bg(), highlightthickness=0, bd=0)
self.optimize_map_canvas.pack(fill="both", expand=True)
bind_pan(self.optimize_map_canvas)
self._optimize_map_images = []
self._route_optimization_result = None # RouteOptimizationResult -- None, доки не запущено
self._optimize_map_resize_after_id = None
def _on_optimize_map_configure(event):
if self._optimize_map_resize_after_id is not None:
self.after_cancel(self._optimize_map_resize_after_id)
self._optimize_map_resize_after_id = self.after(150, self._load_optimization_map)
self.optimize_map_canvas.bind("<Configure>", _on_optimize_map_configure)
optimize_report_box = ttk.LabelFrame(optimize_inner, height=initial_viewport_h)
self._reg_i18n(optimize_report_box, "text", "box_optimization_report")
optimize_report_box.pack(fill="x", pady=(0, 8))
optimize_report_box.pack_propagate(False)
self._optimize_report_box = optimize_report_box
# ТОЧНА пікселева висота через pack_propagate(False) на самому
# контейнері -- той самий метод, що вже гарантовано точно працює
# для карти (populated_map_box/optimize_map_box): текст просто
# ЗАПОВНЮЄ контейнер (fill="both", height=1 -- значення не має
# значення, розмір диктує контейнер), а не навпаки (як робив
# раніше -- рахував рядки тексту під висоту, це НАБЛИЖЕНО через
# метрику шрифту, тому завжди трохи розходилось з реальним
# пікселевим розміром карти вище -- саме тому "майже повний
# екран" замість точно повного).
optimize_report_inner = tk.Frame(optimize_report_box)
optimize_report_inner.pack(fill="both", expand=True, padx=4, pady=4)
self.optimize_report_text = make_plain_text(optimize_report_inner, height=1)
optimize_report_scroll = ttk.Scrollbar(
optimize_report_inner, orient="vertical", command=self.optimize_report_text.yview,
)
self.optimize_report_text.configure(yscrollcommand=optimize_report_scroll.set)
self.optimize_report_text.pack(side="left", fill="both", expand=True)
optimize_report_scroll.pack(side="right", fill="y")
# --- «Висота» -- ЗАПЛАНОВАНА, ще НЕ реалізована оптимізація
# висотного профілю (див. докстрінг модуля вище). Поки що --
# чесна заглушка, а не порожня вкладка без пояснення.
altitude_tab, altitude_inner = make_scroll_tab("tab_altitude_optimization")
alt_route_type_row = ttk.Frame(altitude_inner)
alt_route_type_row.pack(fill="x", pady=(0, 6))
self._reg_i18n(ttk.Label(alt_route_type_row), "text", "lbl_route_type").pack(side="left", padx=(0, 4))
self.alt_route_type_var = tk.StringVar(value="")
self.alt_route_type_box = ttk.Combobox(
alt_route_type_row, textvariable=self.alt_route_type_var, state="readonly", width=22,
)
self.alt_route_type_box.pack(side="left")
# список типів читається заново ПЕРЕД кожним відкриттям (не
# тільки при побудові сторінки) -- той самий принцип, що вже
# є для профілю літака на вкладці "Координати": тип міг бути
# доданий у "Конфігурації" вже ПІСЛЯ побудови цієї сторінки
self.alt_route_type_box.bind("<Button-1>", self._refresh_altitude_route_type_list)
self._refresh_altitude_route_type_list()
alt_hint = ttk.Frame(altitude_inner)
alt_hint.pack(fill="x", pady=(0, 8))
self._reg_i18n(
ttk.Label(alt_hint, foreground="#888", wraplength=600, justify="left"),
"text", "hint_altitude_uses_coordinate_result",
).pack(anchor="w")
alt_controls = ttk.Frame(altitude_inner)
alt_controls.pack(fill="x", pady=(0, 8))
self.optimize_altitude_btn = ttk.Button(alt_controls, command=self._run_altitude_optimization)
self._reg_i18n(self.optimize_altitude_btn, "text", "btn_optimize_altitude")
self.optimize_altitude_btn.pack(side="left")
self.save_altitude_btn = ttk.Button(
alt_controls, command=self._save_altitude_optimized_mission, state="disabled",
)
self._reg_i18n(self.save_altitude_btn, "text", "btn_save_optimized_mission")
self.save_altitude_btn.pack(side="left", padx=(6, 0))
self.alt_step_mode_var = tk.BooleanVar(value=False)
self._reg_i18n(
ttk.Checkbutton(alt_controls, variable=self.alt_step_mode_var), "text", "chk_altitude_step_mode",
).pack(side="left", padx=(12, 0))
self.alt_step_next_btn = ttk.Button(
alt_controls, command=self._on_altitude_step_next, state="disabled",
)
self._reg_i18n(self.alt_step_next_btn, "text", "btn_altitude_step_next")
self.alt_step_next_btn.pack(side="left", padx=(6, 0))
self.alt_cancel_btn = ttk.Button(
alt_controls, command=self._on_altitude_cancel, state="disabled",
)
self._reg_i18n(self.alt_cancel_btn, "text", "btn_altitude_cancel")
self.alt_cancel_btn.pack(side="left", padx=(6, 0))
def _on_space_pressed(event):
if not self.alt_step_mode_var.get():
return # покроковий режим вимкнено -- пробіл не наш, не чіпаємо
focused = self.focus_get()
if isinstance(focused, (tk.Entry, tk.Text, ttk.Entry, ttk.Combobox)):
return # фокус у полі вводу -- пробіл має вводити символ, не просувати крок
self._on_altitude_step_next()
# bind_all -- НЕ self.bind: останній спрацьовує лише коли
# фокус клавіатури саме на головному вікні, а ПІСЛЯ кліку на
# "Оптимізувати висоту" фокус лишається на самій кнопці, яка
# перехоплює пробіл (стандартна поведінка Tkinter -- фокусна
# кнопка сама реагує на пробіл, як на клік). bind_all працює
# незалежно від того, який віджет має фокус -- але тому й
# потребує захисту вище, щоб не заважати звичайному вводу тексту.
self.bind_all("<space>", _on_space_pressed)
self.altitude_status_var = tk.StringVar(value="")
ttk.Label(altitude_inner, textvariable=self.altitude_status_var, foreground="#888").pack(
anchor="w", pady=(0, 4)
)
altitude_chart_box = ttk.LabelFrame(altitude_inner, height=initial_viewport_h)
self._reg_i18n(altitude_chart_box, "text", "box_altitude_chart")
altitude_chart_box.pack(fill="x", pady=(0, 8))
altitude_chart_box.pack_propagate(False)
self.altitude_chart_canvas = tk.Canvas(
altitude_chart_box, bg=self._graph_canvas_bg(), highlightthickness=0,
)
self.altitude_chart_canvas.pack(fill="both", expand=True)
def _on_altitude_chart_configure(event):
self._redraw_altitude_chart()
self.altitude_chart_canvas.bind("<Configure>", _on_altitude_chart_configure)
altitude_angle_chart_box = ttk.LabelFrame(altitude_inner, height=int(initial_viewport_h * 0.6))
self._reg_i18n(altitude_angle_chart_box, "text", "box_altitude_angle_chart")
altitude_angle_chart_box.pack(fill="x", pady=(0, 8))
altitude_angle_chart_box.pack_propagate(False)
self.altitude_angle_chart_canvas = tk.Canvas(
altitude_angle_chart_box, bg=self._graph_canvas_bg(), highlightthickness=0,
)
self.altitude_angle_chart_canvas.pack(fill="both", expand=True)
def _on_altitude_angle_chart_configure(event):
self._redraw_altitude_angle_chart()
self.altitude_angle_chart_canvas.bind("<Configure>", _on_altitude_angle_chart_configure)
altitude_report_box = ttk.LabelFrame(altitude_inner, height=initial_viewport_h)
self._reg_i18n(altitude_report_box, "text", "box_altitude_report")
altitude_report_box.pack(fill="x", pady=(0, 8))
altitude_report_box.pack_propagate(False)
altitude_report_inner = tk.Frame(altitude_report_box)
altitude_report_inner.pack(fill="both", expand=True, padx=4, pady=4)
self.altitude_report_text = make_plain_text(altitude_report_inner, height=1)
altitude_report_scroll = ttk.Scrollbar(
altitude_report_inner, orient="vertical", command=self.altitude_report_text.yview,
)
self.altitude_report_text.configure(yscrollcommand=altitude_report_scroll.set)
self.altitude_report_text.pack(side="left", fill="both", expand=True)
altitude_report_scroll.pack(side="right", fill="y")
self._altitude_optimization_result = None
self._altitude_nav_wps = None
def _run_route_optimization(self):
"""Кнопка "Оптимізувати маршрут" -- валідація введених даних,
запуск у фоновому потоці (мережеві запити Overpass API на
кожне ребро)."""
if self.analyzer is None or not self.analyzer.nav_wps:
messagebox.showwarning(i18n.t("msg_weather_title"), i18n.t("msg_load_mission_first_body"))
return
# радіус обльоту тепер ЗАЛЕЖИТЬ ВІД НАСЕЛЕННЯ конкретного НП
# (з "Тип маршруту" у Конфігурації) -- ЗА ПРЯМОЮ ВКАЗІВКОЮ
# користувача, замінює колишнє єдине поле-поріг на вкладці
# "Аналіз". Без обраного типу маршруту координатна оптимізація
# просто не має звідки взяти радіус -- явне попередження, а не
# тиха робота з випадковим значенням за замовчуванням.
route_type_store = route_types.load_route_types(route_types.default_route_types_path())
route_type = route_type_store.get_current()
if route_type is None:
messagebox.showwarning(i18n.t("msg_weather_title"), i18n.t("msg_no_route_type_for_coords"))
return
try:
tank_capacity = float(self.opt_tank_capacity_var.get().replace(",", "."))
cruise_consumption = float(self.opt_cruise_consumption_var.get().replace(",", "."))
cruise_speed = float(self.opt_cruise_speed_var.get().replace(",", "."))
roll_limit = float(self.opt_roll_limit_var.get().replace(",", "."))
exclude_legs = int(self.opt_exclude_legs_var.get())
if (tank_capacity <= 0 or cruise_consumption <= 0
or cruise_speed <= 0 or roll_limit <= 0 or exclude_legs < 0):
raise ValueError
except ValueError:
messagebox.showwarning(i18n.t("msg_weather_title"), i18n.t("msg_invalid_optimization_input_body"))
return
fuel_budget = route_optimizer.FuelBudget(
tank_capacity_l=tank_capacity,
cruise_consumption_lph=cruise_consumption,
cruise_speed_kmh=cruise_speed,
)
self.optimize_route_btn.configure(state="disabled")
self.save_optimized_btn.configure(state="disabled")
self.optimize_status_var.set(i18n.t("status_optimizing_route"))
# частковий стан для ПРОГРЕСИВНОГО малювання карти -- оригінальний
# маршрут відомий одразу (без мережі, з nav_wps), малюємо його
# негайно; new_route наповнюється по мірі готовності кожного
# ребра (on_progress у _route_optimization_worker)
self._route_optimization_result = None
self._optimize_partial_new_route = [(self.analyzer.nav_wps[0].lat, self.analyzer.nav_wps[0].lon)]
self._load_optimization_map()
threading.Thread(
target=self._route_optimization_worker,
args=(route_type, exclude_legs, fuel_budget, roll_limit),
daemon=True,
).start()
def _route_optimization_worker(self, route_type, exclude_legs, fuel_budget, roll_limit):
"""Фоновий потік: викликає route_optimizer.optimize_route() з
РЕАЛЬНИМ settlements_fetcher (populated_areas.fetch_settlements).
НІЯКОГО звернення до Tkinter напряму -- лише через self.after(0, ...)."""
try:
def fetcher(lat_min, lat_max, lon_min, lon_max):
# той самий шар заборонних зон, що й "Обліт НП"
# (restricted_zones.py) -- гарантовано той самий набір
# сіл для того самого маршруту, і мережа торкається лише
# дійсно нових ділянок, не всього bbox щоразу заново
settlements, all_covered = restricted_zones.get_settlements_for_bbox(
lat_min, lat_max, lon_min, lon_max,
)
# ТИМЧАСОВЕ діагностичне вивантаження (за прямою
# вказівкою користувача) -- список сіл, які реально
# прийшли для КОЖНОГО запиту bbox, дописується у файл
# у поточній робочій директорії. НЕ чіпає інтерфейс.
try:
import json as _json
dump_path = "debug_settlements_dump.json"
try:
with open(dump_path, "r", encoding="utf-8") as f:
dump_data = _json.load(f)
except (FileNotFoundError, ValueError):
dump_data = []
dump_data.append({
"bbox": [lat_min, lat_max, lon_min, lon_max],
"all_covered": all_covered,
"settlements": settlements,
})
with open(dump_path, "w", encoding="utf-8") as f:
_json.dump(dump_data, f, ensure_ascii=False, indent=2)
except Exception:
pass # діагностичне вивантаження НІКОЛИ не має зривати сам розрахунок
if not all_covered:
self.after(0, lambda: self.optimize_status_var.set(
i18n.t("status_zones_partial_coverage"),
))
return settlements
def on_progress(done, total, leg_result):
self.after(0, lambda: self.optimize_status_var.set(
i18n.t("status_optimizing_leg_fmt", done=done, total=total)
))
# прогресивне домальовування карти -- ЦЕЙ саме потік
# (фоновий) лише ДОПИСУЄ у список (проста atomic-подібна
# операція append, безпечна без блокувань -- єдиний
# writer тут, головний потік лише ЧИТАЄ через after());
# саме малювання завжди відбувається в головному потоці
# Tkinter, як і має бути -- Canvas не можна чіпати з
# фонового потоку напряму
self._optimize_partial_new_route.extend(leg_result.inserted_waypoints)
for wp in self.analyzer.nav_wps[leg_result.leg_index + 1:leg_result.leg_index + 2]:
self._optimize_partial_new_route.append((wp.lat, wp.lon))
self.after(0, self._load_optimization_map)
# ЗА ПРЯМОЮ ВКАЗІВКОЮ користувача: ребро, що перетинає
# державний кордон, НЕ обходить населені пункти -- щільні
# точки обходу фізично несумісні з відстанню, потрібною
# для плавної зміни висоти на переході (реальний баг: 57°
# кут, коли перехід висоти намагався втиснутись у 129м
# відрізок обходу). Замість примирення двох несумісних
# вимог в одному місці -- на такому ребрі просто немає
# обходу, як і для "зони посадки".
exclude_leg_indices = set()
try:
border_polygons = self._get_country_border_polygon("RUS")
if border_polygons:
nav_wps = self.analyzer.nav_wps
in_occupied = [
altitude_optimizer.point_in_any_polygon(wp.lat, wp.lon, border_polygons)
for wp in nav_wps
]
for k in range(len(nav_wps) - 1):
if in_occupied[k] != in_occupied[k + 1]:
exclude_leg_indices.add(k)
except Exception:
pass # відсутність кордону НЕ має зривати саму координатну оптимізацію
result = route_optimizer.optimize_route(
self.analyzer.nav_wps, fetcher, route_type.radius_for_population,
exclude_last_n_legs=exclude_legs, exclude_leg_indices=exclude_leg_indices,
fuel_budget=fuel_budget,
roll_limit_deg=roll_limit,
min_radius_km=min(r for _, r in route_type.population_radii_km),
progress_callback=on_progress,
)
self.after(0, lambda: self._on_route_optimization_ready(result, None))
except populated_areas.OverpassError as e:
self.after(0, lambda: self._on_route_optimization_ready(None, str(e)))
except Exception as e:
self.after(0, lambda: self._on_route_optimization_ready(None, str(e)))
def _on_route_optimization_ready(self, result, error):
if error:
# помилка -- РОЗБЛОКОВУЄМО "Оптимізувати" (нічого не вдалось,
# користувач має змогу спробувати ще раз, напр. після
# тимчасового мережевого збою). НЕ розблоковуємо при УСПІХУ
# (нижче) -- оптимізовану місію нема сенсу оптимізувати ще
# раз, доти, доки користувач сам не змінить якийсь параметр.
self.optimize_route_btn.configure(state="normal")
self.optimize_status_var.set(i18n.t("status_settlements_error_fmt", error=error))
# статус-рядок (ttk.Label) НЕ підтримує виділення мишею взагалі
# -- дублюємо повний текст помилки в optimize_report_text, де
# вже є копіювання правою кнопкою (theme.make_text_readonly),
# щоб користувач реально міг скопіювати й переслати текст
self.optimize_report_text.configure(state="normal")
self.optimize_report_text.delete("1.0", "end")
self.optimize_report_text.insert("end", i18n.t("status_settlements_error_fmt", error=error))
theme.make_text_readonly(self.optimize_report_text)
return
self._route_optimization_result = result
self.save_optimized_btn.configure(state="normal")
self.optimize_status_var.set(i18n.t(
"status_optimization_done_fmt",
added=result.added_distance_km, legs=len(result.legs),
))
lines = [i18n.t("box_optimization_report"), "-" * 44, ""]
lines.append(i18n.t("opt_distance_before_fmt", km=result.total_original_distance_km))
lines.append(i18n.t("opt_distance_after_fmt", km=result.total_new_distance_km))
lines.append(i18n.t("opt_distance_added_fmt", km=result.added_distance_km))
lines.append("")
lines.append(i18n.t("opt_waypoints_fmt", n=result.total_waypoints, limit=route_optimizer.MAX_MISSION_WAYPOINTS))
if result.waypoint_limit_exceeded:
lines.append(" " + i18n.t("opt_waypoint_limit_exceeded"))
lines.append("")
if result.fuel_check is not None:
fc = result.fuel_check
lines.append(i18n.t("opt_fuel_trip_fmt", l=fc.trip_fuel_l))
lines.append(i18n.t("opt_fuel_reserve_fmt", l=fc.reserve_l))
lines.append(i18n.t("opt_fuel_required_fmt", l=fc.required_total_l))
lines.append(i18n.t("opt_fuel_tank_fmt", l=fc.tank_capacity_l))
status_key = "opt_fuel_ok" if fc.feasible else "opt_fuel_deficit"
lines.append(i18n.t(status_key, margin=abs(fc.margin_l)))
lines.append("")
if result.turn_check is not None:
tc = result.turn_check
lines.append(i18n.t("opt_turn_radius_fmt", r=tc.min_turn_radius_m, threshold=tc.threshold_m))
turn_status_key = "opt_turn_ok" if tc.feasible else "opt_turn_deficit"
lines.append(i18n.t(turn_status_key, margin=abs(tc.margin_m)))
lines.append("")
failed_legs = [lr for lr in result.legs if lr.failed]
if failed_legs:
lines.append(i18n.t("opt_failed_legs_header_fmt", n=len(failed_legs)))
for lr in failed_legs:
lines.append(f" {i18n.t('settlement_col_leg')} {lr.leg_index}: {lr.failure_reason}")
lines.append("")
merged_legs = [lr for lr in result.legs if lr.merged_with_next]
if merged_legs:
lines.append(i18n.t("opt_merged_legs_header_fmt", n=len(merged_legs)))
for lr in merged_legs:
lines.append(i18n.t(
"opt_merged_leg_line_fmt",
leg1=lr.leg_index, leg2=lr.leg_index + 1, removed=lr.removed_waypoint_index,
))
lines.append("")
# --- таблиця "Було/Стало" по кожному НП -- пряме числове
# підтвердження, що обхід реально спрацював: "було" (відстань
# від ПРЯМОЇ лінії ребра) мала бути < порогу, "стало" (відстань
# від НОВОГО шляху з обходом) -- >= порогу. Той самий формат
# колонок, що й таблиця "Обліт НП" -- для узгодженості. Поріг
# тепер ІНДИВІДУАЛЬНИЙ для кожного НП (obs.radius_km, залежить
# від його населення) -- НЕ єдине число для всіх одразу.
any_obstacles = any(
populated_areas._point_to_segment_m(
obs.lat, obs.lon,
self.analyzer.nav_wps[lr.leg_index].lat, self.analyzer.nav_wps[lr.leg_index].lon,
self.analyzer.nav_wps[
lr.removed_waypoint_index + 1 if lr.merged_with_next else lr.leg_index + 1
].lat,
self.analyzer.nav_wps[
lr.removed_waypoint_index + 1 if lr.merged_with_next else lr.leg_index + 1
].lon,
) < obs.radius_km * 1000.0
for lr in result.legs for obs in lr.obstacles_considered
)
lines.append(i18n.t("opt_before_after_header"))
lines.append("-" * 90)
if not any_obstacles:
# ЯВНЕ повідомлення, а не тиха відсутність розділу -- інакше
# неможливо відрізнити "жодного НП поруч, усе безпечно" від
# "щось не спрацювало" (виявлено на практиці: користувач не
# міг зрозуміти, чому таблиця відсутня)
lines.append(i18n.t("opt_no_obstacles_considered"))
else:
col_num = i18n.t("settlement_col_num")
col_leg = i18n.t("settlement_col_leg")
col_name = i18n.t("settlement_col_name")
col_before = i18n.t("opt_col_before")
col_after = i18n.t("opt_col_after")
lines.append(f"{col_num:<5}{col_leg:<7}{col_name:<28}{col_before:<15}{col_after}")
row_num = 0
for lr in result.legs:
if not lr.obstacles_considered:
continue
wp1 = self.analyzer.nav_wps[lr.leg_index]
end_index = lr.removed_waypoint_index + 1 if lr.merged_with_next else lr.leg_index + 1
wp2 = self.analyzer.nav_wps[end_index]
new_path = [(wp1.lat, wp1.lon)] + lr.inserted_waypoints + [(wp2.lat, wp2.lon)]
for obs in lr.obstacles_considered:
before_m = populated_areas._point_to_segment_m(
obs.lat, obs.lon, wp1.lat, wp1.lon, wp2.lat, wp2.lon,
)
# ПОКАЗУЄМО ТІЛЬКИ СПРАВЖНІ ПОРУШЕННЯ (Було < поріг) --
# obstacles_considered містить УСІ НП у радіусі 3×
# поріг (потрібно для побудови графа обходу -- щоб
# враховувати потенційно релевантні перешкоди, не
# тільки вже порушені), але показ ЦЬОГО ширшого
# контексту в таблиці лише спантеличує: "Було: 2843м"
# -- це взагалі НЕ порушення, і користувач не може
# відрізнити реальну проблему від шуму (звідси плутанина
# "Обліт НП знайшов 54, а тут 189" -- 189 містило й
# усе безпечне з 3х радіуса, не тільки порушення).
if before_m >= obs.radius_km * 1000.0:
continue
row_num += 1
after_m = min(
populated_areas._point_to_segment_m(
obs.lat, obs.lon,
new_path[i][0], new_path[i][1], new_path[i + 1][0], new_path[i + 1][1],
)
for i in range(len(new_path) - 1)
)
before_str = f"{before_m:.0f}м"
# для FAILED ребра "Стало" завжди == "Було" (жодних
# точок обходу не вставлено -- шлях лишився прямою
# лінією), АЛЕ це виглядало б ідентично випадку "тут
# і так було безпечно" -- явно позначаємо різницю,
# інакше користувач не зрозуміє, ЧОМУ немає покращення
if lr.failed:
after_str = i18n.t("opt_leg_failed_marker")
else:
after_str = f"{after_m:.0f}м"
lines.append(f"{row_num:<5}{lr.leg_index:<7}{obs.name:<28}{before_str:<15}{after_str}")
self.optimize_report_text.configure(state="normal")
self.optimize_report_text.delete("1.0", "end")
self.optimize_report_text.insert("end", "\n".join(lines))
theme.make_text_readonly(self.optimize_report_text)
self._load_optimization_map()
def _load_optimization_map(self):
"""Малює карту з ОБОМА маршрутами одна поверх одної: "було"
(оригінальний, тонший/тьмяніший) і "стало" (оптимізований,
яскравіший, поверх). Використовує ГОТОВИЙ знімок тайлів з
"Місія" (_initial_map_render) -- та сама карта, що й "Обліт НП"
і "Маршрут"."""
if self.analyzer is None or not hasattr(self, "optimize_map_canvas"):
return
snapshot = getattr(self, "_initial_map_render", None)
if snapshot is None:
return
zoom = snapshot["zoom"]
tiles = snapshot["tiles"]
tx_min, tx_max = snapshot["tx_min"], snapshot["tx_max"]
ty_min, ty_max = snapshot["ty_min"], snapshot["ty_max"]
center_lat, center_lon = snapshot["center_lat"], snapshot["center_lon"]
result = render_viewport(
self.optimize_map_canvas, self.analyzer, zoom, center_lat, center_lon,
tx_min, tx_max, ty_min, ty_max, tiles, self._optimize_map_images,
)
screen_origin_gx, screen_origin_gy = result[4], result[5]
opt_result = self._route_optimization_result
def draw_polyline(points, color, width):
coords = []
for lat, lon in points:
gx, gy = lonlat_to_pixel(lat, lon, zoom)
coords.append(gx - screen_origin_gx)
coords.append(gy - screen_origin_gy)
if len(coords) >= 4:
self.optimize_map_canvas.create_line(
*coords, fill=color, width=width, tags=("opt_route",),
)
if opt_result is not None:
# фінальний результат готовий -- малюємо ПОВНІ обидва маршрути
original_route = opt_result.original_route
new_route = opt_result.new_route
legs_for_markers = opt_result.legs
else:
# розрахунок ще триває -- оригінальний маршрут відомий ОДРАЗУ
# (без мережі, прямо з nav_wps), новий -- лише те, що вже
# встигли порахувати (self._optimize_partial_new_route,
# наповнюється в on_progress по мірі готовності кожного ребра)
partial = getattr(self, "_optimize_partial_new_route", None)
if partial is None:
return
original_route = [(wp.lat, wp.lon) for wp in self.analyzer.nav_wps]
new_route = partial
legs_for_markers = []
# "було" -- тьмяний, тонший, під "стало"
draw_polyline(original_route, "#888888", 2)
# "стало" -- яскравий, товщий, поверх (росте по мірі готовності)
draw_polyline(new_route, "#00c853", 3)
# позначки вставлених точок обходу (маленькі жовті кружечки)
for leg in legs_for_markers:
for lat, lon in leg.inserted_waypoints:
gx, gy = lonlat_to_pixel(lat, lon, zoom)
x, y = gx - screen_origin_gx, gy - screen_origin_gy
self.optimize_map_canvas.create_oval(
x - 3, y - 3, x + 3, y + 3,
fill="#ffd600", outline="black", width=1, tags=("opt_route",),
)
def _compute_detour_altitude(self, wp1, wp2, inserted_points: list[tuple[float, float]]):
"""Висота вставлених точок обходу.
Якщо обрано тип маршруту (Конфігурація) І вдалось отримати
державний кордон -- НОВА, проста логіка: для кожної точки
(рельєф_у_ЦІЙ_точці + цільова відносна висота ТІЄЇ зони, де
вона фактично лежить), а для пари точок на самому ребрі, де
відбувається перетин кордону -- висота переходу з типу
маршруту. Жодного розрахунку маневру чи плавного переходу --
свідомо просто, оцінка результату на графіку "Було/Стало"
станеться ОКРЕМИМ кроком, після цього.
Якщо тип маршруту НЕ обрано (чи кордон не вдалось отримати) --
ЗАПАСНИЙ варіант, як і раніше: висота_рельєфу(нова_точка) +
середнє_відносних_висот(wp1, wp2) -- НЕ лінійна інтерполяція
абсолютної висоти (рельєф під дугою обходу може суттєво
відрізнятись від прямої лінії між висотами кінців)."""
analyzer = self.analyzer
terrain = analyzer.terrain
target_frame = wp1.frame
home_amsl = analyzer.home_amsl or 0.0
def _alt_for_frame(abs_alt, terrain_h, target_agl):
if target_frame in (0, 2):
return abs_alt
if target_frame == 10:
return target_agl if target_agl is not None else abs_alt - terrain_h
return abs_alt - home_amsl
# --- Спроба НОВОЇ, простої логіки (тип маршруту + кордон) ---
try:
route_type_store = route_types.load_route_types(route_types.default_route_types_path())
route_type = route_type_store.get_current()
except Exception:
route_type = None
polygons = self._get_country_border_polygon("RUS") if route_type is not None else None
if route_type is not None and polygons:
# РОЗДІЛЕНО (за прямою вказівкою користувача): обліт НП і
# набір/зниження висоти на переході кордону -- ДВІ РІЗНІ
# задачі, які НЕ МОЖНА змішувати. Точки обходу тут
# розставлені щільно (десятки-сотні метрів між ними,
# дискретизація дуги навколо перешкоди) -- вони НЕ мають
# жодного стосунку до того, скільки відстані фізично
# потрібно для плавної зміни висоти. РАНІШЕ тут ПОМИЛКОВО
# шукався перехід кордону САМЕ всередині цих щільних точок
# і туди ж напряму втискалась ціль border_crossing_m --
# ВИЯВЛЕНО НА ПРАКТИЦІ: коли border_crossing_m суттєво
# відрізняється від сусідньої зони, це давало кут нахилу
# ~57° на відрізку в 129м -- фізично неможливий маневр.
# Тепер КОЖНА точка обходу отримує ПРОСТО ціль своєї зони
# (контрольована/окупована, БЕЗ спеціального "перехідного"
# значення) -- плавний, з урахуванням дистанції, перехід
# через сам кордон -- виключна відповідальність окремого,
# правильного кроку "Висота" (Фаза 1: контроль кута).
all_pts = [(wp1.lat, wp1.lon)] + list(inserted_points) + [(wp2.lat, wp2.lon)]
in_occupied = [
altitude_optimizer.point_in_any_polygon(lat, lon, polygons) for lat, lon in all_pts
]
result = []
for k, (lat, lon) in enumerate(all_pts[1:-1], start=1): # тільки ВСТАВЛЕНІ (без wp1/wp2)
try:
terrain_h = terrain.get_elevation(lat, lon)
except SRTMError:
terrain_h = None
if terrain_h is None:
continue # немає рельєфу саме тут -- пропускаємо, не вигадуємо число
target_agl = route_type.altitude_occupied_m if in_occupied[k] else route_type.altitude_controlled_m
abs_alt = terrain_h + target_agl
result.append(Waypoint(
index=0, current=0, frame=target_frame, command=16,
param1=0.0, param2=0.0, param3=0.0, param4=0.0,
lat=lat, lon=lon, alt=_alt_for_frame(abs_alt, terrain_h, target_agl), autocontinue=1,
))
if len(result) == len(inserted_points):
return result
# якщо десь рельєфу не вистачило (пропущені точки вище) --
# НЕ повертаємо частковий/неповний список, падаємо назад
# на запасний варіант нижче для ВСІХ точок разом, чесніше,
# ніж мовчки короткий список
# --- ЗАПАСНИЙ варіант (тип маршруту не обрано чи немає кордону) ---
abs1 = analyzer._absolute_alt(wp1)
abs2 = analyzer._absolute_alt(wp2)
try:
terrain1 = terrain.get_elevation(wp1.lat, wp1.lon) if wp1.has_position else None
except SRTMError:
terrain1 = None
try:
terrain2 = terrain.get_elevation(wp2.lat, wp2.lon) if wp2.has_position else None
except SRTMError:
terrain2 = None
rel1 = (abs1 - terrain1) if (abs1 is not None and terrain1 is not None) else None
rel2 = (abs2 - terrain2) if (abs2 is not None and terrain2 is not None) else None
if rel1 is None and rel2 is None:
raise ValueError(i18n.t("msg_no_terrain_for_leg_fmt", leg=""))
avg_rel = rel1 if rel2 is None else (rel2 if rel1 is None else (rel1 + rel2) / 2.0)
fallback_terrain = None
if terrain1 is not None and terrain2 is not None:
fallback_terrain = (terrain1 + terrain2) / 2.0
elif terrain1 is not None:
fallback_terrain = terrain1
elif terrain2 is not None:
fallback_terrain = terrain2
result = []
for lat, lon in inserted_points:
try:
terrain_h = terrain.get_elevation(lat, lon)
except SRTMError:
if fallback_terrain is None:
raise ValueError(i18n.t("msg_no_terrain_for_leg_fmt", leg=""))
terrain_h = fallback_terrain
abs_alt = terrain_h + avg_rel
result.append(Waypoint(
index=0, current=0, frame=target_frame, command=16,
param1=0.0, param2=0.0, param3=0.0, param4=0.0,
lat=lat, lon=lon, alt=_alt_for_frame(abs_alt, terrain_h, None), autocontinue=1,
))
return result
def _build_optimized_waypoints(self) -> tuple[list, dict]:
"""Реконструює ПОВНИЙ список Waypoint (не тільки nav_wps) з
вставленими точками обходу на правильних місцях -- зберігає всі
інші елементи місії (home, зліт, DO_-команди тощо) на своїх
позиціях відносно навігаційних точок, як у оригінальному файлі.
Для ОБ'ЄДНАНИХ ребер (merged_with_next): вейпоінт МІЖ двома
об'єднаними ребрами дійсно ВИДАЛЯЄТЬСЯ з фінального маршруту (не
просто отримує вставлені точки ПЕРЕД собою, як було в
попередній версії -- реальний баг, видалена точка лишалась у
виводі). Будь-які НЕ-навігаційні команди, що стояли в оригіналі
БЕЗПОСЕРЕДНЬО біля цієї точки (до чи після), переносяться на
НАЙБЛИЖЧУ (за реальною географічною відстанню, не за порядком у
списку) нову точку обходу -- а не мовчки губляться.
Повертає (new_wps, summary) -- summary збирає ЛЮДСЬКИ читабельний
підсумок усіх таких "непомітних" змін (перенесені команди,
скориговані/перенаправлені DO_JUMP), щоб оператор бачив це явно
в звіті після збереження, а не дізнавався постфактум із
поведінки місії в польоті."""
result_obj = self._route_optimization_result
if result_obj is None:
raise ValueError(i18n.t("msg_no_optimization_result"))
analyzer = self.analyzer
if analyzer.terrain is None:
raise ValueError(i18n.t("msg_no_terrain_for_save"))
summary = {"relocated_commands": [], "jump_redirected": [], "jump_renumbered": 0}
nav_wps = analyzer.nav_wps
legs_by_index = {lr.leg_index: lr for lr in result_obj.legs}
all_wps = analyzer.all_wps
# nav_wps-індекс -> Waypoint-об'єкт, що ВИДАЛЯЄТЬСЯ через злиття
removed_wp_objects = {
lr.removed_waypoint_index: nav_wps[lr.removed_waypoint_index]
for lr in result_obj.legs if lr.merged_with_next
}
removed_wp_ids = {id(obj) for obj in removed_wp_objects.values()}
def _next_nav_point(from_idx: int):
"""Найближча НАВІГАЦІЙНА точка (Waypoint), що йде ПІСЛЯ
all_wps[from_idx] у послідовності -- для перевірки, чи
команда стоїть БЕЗПОСЕРЕДНЬО перед видаленою точкою."""
for j in range(from_idx, len(all_wps)):
if all_wps[j].is_nav_point:
return all_wps[j]
return None
new_wps: list[Waypoint] = []
nav_wps_position = 0 # позиція В nav_wps ДЛЯ ЦІЄЇ точки (рахує ВСІ, включно з видаленими)
last_real_nav_index = -1 # nav_wps-індекс ОСТАННЬОЇ РЕАЛЬНО збереженої точки (не рахує видалені)
pending_relocatable: list = [] # НЕ-нав команди, що стояли ПОРУЧ із видаленою точкою (до чи після)
just_passed_removed = False # True одразу після пропущеної видаленої точки -- команди
# звідси й до НАСТУПНОЇ реальної нав-точки теж вважаються "поруч" і йдуть у relocatable
for idx, wp in enumerate(all_wps):
if wp.is_nav_point:
if id(wp) in removed_wp_ids:
# ЦЯ точка зникає з маршруту -- НЕ додаємо в new_wps.
# pending_relocatable (команди, що йшли перед нею)
# розберемо нижче, коли дійдемо до точок об'єднаного
# обходу для цього злиття. Команди, що йдуть ПІСЛЯ
# (до наступної реальної нав-точки) -- теж relocatable,
# позначаємо прапорцем.
just_passed_removed = True
nav_wps_position += 1
continue
just_passed_removed = False
if last_real_nav_index >= 0:
# КЛЮЧОВЕ виправлення: leg_idx -- це індекс ОСТАННЬОЇ
# реально збереженої точки (last_real_nav_index), НЕ
# простий лічильник пройдених нав-точок. Якщо між
# нею й ЦІЄЮ точкою була видалена (злиття) -- це
# ОБ'ЄДНАНЕ ребро, а не звичайне "наступне".
leg_idx = last_real_nav_index
leg_result = legs_by_index.get(leg_idx)
if leg_result and leg_result.inserted_waypoints:
wp1 = nav_wps[leg_idx]
end_idx = (
leg_result.removed_waypoint_index + 1
if leg_result.merged_with_next else leg_idx + 1
)
wp2 = nav_wps[end_idx]
detour_wps = self._compute_detour_altitude(
wp1, wp2, leg_result.inserted_waypoints,
)
if leg_result.merged_with_next and pending_relocatable:
old_removed = removed_wp_objects[leg_result.removed_waypoint_index]
nearest_i = min(
range(len(detour_wps)),
key=lambda i: haversine_m(
old_removed.lat, old_removed.lon,
detour_wps[i].lat, detour_wps[i].lon,
),
)
new_wps.extend(detour_wps[:nearest_i + 1])
new_wps.extend(pending_relocatable)
new_wps.extend(detour_wps[nearest_i + 1:])
summary["relocated_commands"].append({
"commands": [w.command for w in pending_relocatable],
"old_waypoint_index": leg_result.removed_waypoint_index,
})
pending_relocatable = []
else:
new_wps.extend(detour_wps)
last_real_nav_index = nav_wps_position
nav_wps_position += 1
new_wps.append(wp)
else:
# НЕ-навігаційна команда. Relocatable, якщо стоїть
# БЕЗПОСЕРЕДНЬО ПЕРЕД видаленою точкою (наступна
# нав-точка попереду -- видалена) АБО ОДРАЗУ ПІСЛЯ неї
# (just_passed_removed) -- в обох випадках вона
# "прив'язана" саме до видаленої точки, не до жодної
# реальної, що лишається в маршруті.
next_nav = _next_nav_point(idx + 1)
is_before_removed = next_nav is not None and id(next_nav) in removed_wp_ids
if is_before_removed or just_passed_removed:
pending_relocatable.append(wp)
else: