Загрузка данных
# -*- coding: utf-8 -*-
# gui/forecast_window.py
import tkinter as tk
import sqlite3
from tkinter import ttk
from modules.forecasting import monte_carlo_forecast
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import numpy as np
# --- НОВЫЙ БЛОК: Класс для виджета Аккордеон (финальный) ---
class AccordionFrame(ttk.Frame):
"""
Виджет "Аккордеон" для tkinter с автоматическим выравниванием заголовков.
Использует временную метку для измерения ширины текста.
"""
def __init__(self, parent, root_window, **kwargs):
super().__init__(parent, **kwargs)
self.parent = parent
self.root_window = root_window
self.panels = {}
self.current_open = None
self.max_button_width = 0
self.header_frame = ttk.Frame(self)
self.header_frame.pack(fill="x", padx=10, pady=(10, 0))
self.content_frame = ttk.Frame(self)
self.content_frame.pack(fill="both", expand=True, padx=10, pady=5)
def add_panel(self, title, content_frame):
"""
Добавляет новую панель в аккордеон.
Автоматически выравнивает ширину всех кнопок-заголовков.
"""
self.panels[title] = {'frame': content_frame, 'state': 'closed'}
def on_click():
if self.current_open == title:
self._hide_panel(title)
self.current_open = None
else:
if self.current_open:
self._hide_panel(self.current_open)
self._show_panel(title)
self.current_open = title
btn = ttk.Button(self.header_frame, text=f"+ {title}", command=on_click)
# --- ИСПРАВЛЕННАЯ ЛОГИКА ИЗМЕРЕНИЯ ---
# Создаем временную метку (Label) для измерения ширины текста.
# Это самый надежный способ в Tkinter.
try:
temp_label = tk.Label(self.root_window)
text_width = temp_label.winfo_font().measure(f"+ {title}")
temp_label.destroy() # Убираем временный виджет
if text_width > self.max_button_width:
self.max_button_width = text_width
for panel_info in self.panels.values():
if 'button' in panel_info:
panel_info['button'].config(width=self.max_button_width)
except Exception as e:
print(f"[DEBUG] Ошибка при выравнивании заголовков: {e}. Заголовки могут быть неровными.")
btn.pack(anchor='w', pady=2, fill='x')
self.panels[title]['button'] = btn
def _show_panel(self, title):
"""Показывает панель"""
panel_info = self.panels[title]
panel_info['frame'].pack(fill="both", expand=True, padx=10, pady=10)
panel_info['button'].config(text=f"- {title}")
def _hide_panel(self, title):
"""Скрывает панель"""
panel_info = self.panels[title]
panel_info['frame'].pack_forget()
panel_info['button'].config(text=f"+ {title}")
class ForecastWindow:
"""
Окно для отображения прогноза.
Принимает на вход родительское окно (root), чтобы иметь доступ к его данным.
"""
def __init__(self, parent):
# Сохраняем ссылку на главное окно
self.root_window = parent
# Создаем новое окно (Toplevel)
self.window = tk.Toplevel(parent)
self.window.title("Прогнозирование")
self.window.geometry("1050x650")
self.window.resizable(True, True)
# --- ГЛАВНЫЙ КОНТЕЙНЕР ---
main_container = ttk.Frame(self.window)
main_container.pack(fill="both", expand=True)
# Делим главное окно на две части: Верх (График+Настройки) и Низ (Результаты)
main_container.grid_rowconfigure(0, weight=4) # Верхняя часть выше
main_container.grid_rowconfigure(1, weight=1) # Нижняя часть ниже
# --- ВЕРХНИЙ БЛОК (График и Настройки) ---
top_block = ttk.Frame(main_container)
top_block.grid(row=0, column=0, sticky="nsew")
# Внутри верхнего блока делим на два фрейма: График (слева) и Настройки (справа)
top_block.grid_columnconfigure(0, weight=3) # График шире
top_block.grid_columnconfigure(1, weight=2) # Настройки уже
# 1. Левая панель: График
graph_frame = ttk.LabelFrame(top_block, text="График прогноза", padding="10")
graph_frame.grid(row=0, column=0, sticky="nsew")
self.figure = Figure(figsize=(5, 4), dpi=100)
self.ax = self.figure.add_subplot(111)
self.ax.grid(True, linestyle='--', alpha=0.7)
self.canvas = FigureCanvasTkAgg(self.figure, master=graph_frame)
self.canvas.draw()
self.canvas.get_tk_widget().pack(fill="both", expand=True)
# --- ПРАВАЯ ПАНЕЛЬ: ЗАМЕНА НА АККОРДЕОН ---
# ИСПРАВЛЕНО: Передаем self.window как второй аргумент
self.accordion = AccordionFrame(top_block, self.window)
self.accordion.grid(row=0, column=1, sticky="ns")
# --- СОЗДАЕМ ФРЕЙМЫ ДЛЯ КАЖДОЙ ПАНЕЛИ АККОРДЕОНА ---
# Панель 1: Выбор проекта
project_panel = ttk.Frame(self.accordion.content_frame)
ttk.Label(project_panel, text="Выбрать проект:").pack(side="left", anchor="w", padx=(0, 5))
self.project_combobox = ttk.Combobox(project_panel, state="readonly", width=25)
self.project_combobox.pack(side="left", anchor="w")
self.refresh_button = ttk.Button(project_panel, text="Обновить данные", command=self.load_projects)
self.refresh_button.pack(side="left", anchor="w", padx=(10, 0))
self.accordion.add_panel("Основные настройки", project_panel)
# Панель 2: Параметры модели
model_panel = ttk.Frame(self.accordion.content_frame)
input_frame = ttk.Frame(model_panel)
input_frame.pack(fill="x", padx=10, pady=(5, 0))
ttk.Label(input_frame, text="Интенсивность событий (в день):").pack(side="left", anchor="w", padx=(0, 10))
self.lambda_entry = ttk.Entry(input_frame, width=10)
self.lambda_entry.insert(0, "5")
self.lambda_entry.pack(side="left", anchor="w")
ttk.Label(input_frame, text="Шаг (дни):").pack(side="left", anchor="w", padx=(20, 10))
self.step_entry = ttk.Entry(input_frame, width=10)
self.step_entry.insert(0, "1")
self.step_entry.pack(side="left", anchor="w")
# Поле "Прогноз на:"
period_frame = ttk.Frame(model_panel)
period_frame.pack(fill="x", padx=10, pady=(5, 15))
ttk.Label(period_frame, text="Прогноз на:").pack(side="left", anchor="w", padx=(0, 10))
self.period_combobox = ttk.Combobox(period_frame, state="readonly",
values=["Месяц (30 дней)", "Квартал (90 дней)", "Полгода (180 дней)"],
width=25)
self.period_combobox.current(0)
self.period_combobox.pack(side="left", anchor="w")
self.accordion.add_panel("Параметры модели", model_panel)
# Панель 3: Настройки симуляции
sim_panel = ttk.Frame(self.accordion.content_frame)
sim_settings_frame = ttk.Frame(sim_panel)
sim_settings_frame.pack(fill="x", padx=10, pady=(5, 25))
ttk.Label(sim_settings_frame, text="Кол-во итераций:\n(в симуляции)", justify="left").pack(
side="left", anchor="nw", padx=(0, 10))
self.iterations_entry = ttk.Entry(sim_settings_frame, width=25)
self.iterations_entry.insert(0, "10000")
self.iterations_entry.pack(side="left", anchor="w")
# ДОБАВЛЯЕМ ПАНЕЛЬ СРАЗУ ПОСЛЕ ЕЁ СОЗДАНИЯ
self.accordion.add_panel("Настройки симуляции", sim_panel)
# --- ДОБАВЛЯЕМ НОВЫЕ ПАНЕЛИ С ЗАГЛУШКАМИ ---
# Панель 4: Заголовок 4
panel4 = ttk.Frame(self.accordion.content_frame)
tk.Label(panel4, text="Это заглушка для четвертой панели. Здесь будет функционал.").pack(pady=20)
self.accordion.add_panel("Заголовок 4", panel4)
# Панель 5: Заголовок 5
panel5 = ttk.Frame(self.accordion.content_frame)
tk.Label(panel5, text="Это заглушка для пятой панели. Здесь будет функционал.").pack(pady=20)
self.accordion.add_panel("Заголовок 5", panel5)
# Панель 6: Заголовок 6
panel6 = ttk.Frame(self.accordion.content_frame)
tk.Label(panel6, text="Это заглушка для шестой панели. Здесь будет функционал.").pack(pady=20)
self.accordion.add_panel("Заголовок 6", panel6)
# --- КНОПКА "ПОСТРОИТЬ ПРОГНОЗ" ---
# Размещаем её под аккордеоном в той же колонке (column=1), но в новой строке (row=1)
button_frame = ttk.Frame(top_block)
button_frame.grid(row=1, column=1)
self.build_button = ttk.Button(button_frame, text="Построить прогноз", command=self.run_forecast)
self.build_button.pack(pady=30)
# --- НИЖНИЙ БЛОК (Результаты расчета) ---
result_frame = ttk.LabelFrame(main_container, text="Результаты расчета", padding="5")
result_frame.grid(row=1, column=0, sticky="ew")
self.result_text = tk.Text(result_frame, height=2, wrap='word', state='disabled')
scrollbar = ttk.Scrollbar(result_frame, command=self.result_text.yview)
scrollbar.pack(side="right", fill="y")
self.result_text.configure(yscrollcommand=scrollbar.set)
self.result_text.pack(padx=10, pady=5, fill="x")
def _insert_into_result(self, text):
"""Вставляет текст в поле результатов."""
self.result_text.config(state='normal')
self.result_text.insert(tk.END, text + "\n") # Добавляем \n для красоты вывода
self.result_text.config(state='disabled')
self.result_text.see(tk.END) # Автоматически прокручивает вниз
def _clear_result_text(self):
"""Очищает поле результатов перед новым расчетом."""
self.result_text.config(state='normal')
self.result_text.delete(1.0, tk.END)
self.result_text.config(state='disabled')
def load_projects(self):
"""
Загружает список проектов из главного окна в выпадающий список.
"""
try:
projects = self.root_window.project_manager.list_projects()
project_names = [p.name for p in projects]
project_ids = [p.id for p in projects]
self.project_combobox['values'] = project_names
if project_names:
self.project_combobox.current(0)
return True
else:
print("Нет доступных проектов.")
return False
except AttributeError:
print("Ошибка: Не найден project_manager в главном окне.")
return False
def run_forecast(self):
"""
Запускает процесс прогнозирования и отрисовывает результаты.
"""
# Очищаем поле результатов перед новым расчетом
self._clear_result_text()
# --- ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ И ОСНОВНАЯ ЛОГИКА В БЛОКЕ TRY ---
try:
iterations = int(self.iterations_entry.get())
lambda_val = float(self.lambda_entry.get())
selected_period = self.period_combobox.get()
days_forward_dict = {"Месяц": 30, "Квартал": 90, "Полгода": 180}
period_key = next((k for k in days_forward_dict if k in selected_period), None)
days_forward = days_forward_dict.get(period_key, 30)
step_days = int(self.step_entry.get())
# Проверка на положительные значения
if iterations <= 0 or lambda_val <= 0 or days_forward <= 0 or step_days <= 0:
raise ValueError("Все параметры (итерации, интенсивность, период, шаг) должны быть больше нуля.")
if not self.load_projects():
raise ValueError("Не удалось загрузить список проектов. Выберите проект из списка.")
from datetime import datetime, timedelta
import numpy as np
# --- ГЕНЕРАЦИЯ ТРАЕКТОРИИ И СИМУЛЯЦИЯ ---
value_pool_positive = [1, 2, 3, 4, 5]
trajectory_daily = [] # Для точного поиска даты по дням
trajectory_for_plot = [] # Для отрисовки на графике с учетом шага
current_value = 0
start_date = datetime.now().date()
for day_offset in range(days_forward + 1):
# Генерируем события за день и суммируем их влияние
num_events = np.random.poisson(lam=lambda_val)
daily_sum = sum(np.random.choice(value_pool_positive) for _ in range(num_events))
current_value += daily_sum
trajectory_daily.append(current_value) # Сохраняем каждый день
# Добавляем точку на график согласно шагу отображения
if day_offset % step_days == 0 or day_offset == days_forward:
trajectory_for_plot.append(current_value)
# Вывод хода симуляции в КОНСОЛЬ (для отладки)
current_date = start_date + timedelta(days=day_offset)
print(f"{current_date}: Событий: {num_events:2d}. Итог дня: {daily_sum:6.2f}. Накоплено: {current_value:6.2f}")
if not trajectory_for_plot:
raise RuntimeError("Не удалось сгенерировать данные для прогноза.")
# --- РАСЧЕТ ГРАНИЦ ПРОГНОЗА ---
sim_result = monte_carlo_forecast(
pool_values=trajectory_daily,
num_simulations=iterations,
p1=0.05,
p2=0.95
)
final_lower = sim_result["lower_bound"]
final_upper = sim_result["upper_bound"]
final_median = sim_result["median"]
# --- ИЩЕМ ДАТУ ПЕРЕСЕЧЕНИЯ С НИЖНЕЙ ГРАНИЦЕЙ ---
date_of_intersection = None
for idx, val in enumerate(trajectory_daily):
if val >= final_lower:
date_of_intersection = start_date + timedelta(days=idx)
break
# Формируем текст для вывода в виджет в нужном формате
output_text = (
f"ПРОГНОЗ ДОСТИЖЕНИЯ ЦЕЛИ:\n"
f" Цель (Нижняя граница): {final_lower:.2f}\n\n"
f" Прогнозируемый диапазон значений:\n"
f" Минимум: {final_lower:.2f}\n"
f" Максимум: {final_upper:.2f}\n\n"
)
if date_of_intersection:
output_text += f"Достижение минимума прогнозируется: {date_of_intersection.strftime('%d.%m.%Y')}"
else:
output_text += "Достижение минимума в заданный период не прогнозируется."
# --- ВЫВОДИМ РЕЗУЛЬТАТ В ИНТЕРФЕЙС ---
self._insert_into_result(output_text)
# Подготовка данных для графика
result_data = {
"lower_bound": final_lower,
"upper_bound": final_upper,
"median": final_median,
"base_level": trajectory_for_plot[0],
"delta_history": trajectory_for_plot,
"intersections": {
"date": date_of_intersection,
"value": final_lower
}
}
self.update_graph(result_data)
# --- БЛОК ОБРАБОТКИ ОШИБОК (EXCEPT) ---
except ValueError as e:
# Обработка ошибок, связанных с вводом данных (например, буквы вместо цифр)
error_msg = f"Ошибка ввода: {e}. Проверьте все поля."
print(error_msg)
self._insert_into_result("ОШИБКА: " + str(e))
except Exception as e:
# Обработка всех остальных непредвиденных ошибок
error_msg = f"Произошла непредвиденная ошибка: {e}"
print(error_msg)
self._insert_into_result("НЕПРЕДВИДЕННАЯ ОШИБКА: " + str(e))
def get_project_history(self):
"""
Получает историю проекта из БД.
Этот метод может требовать доработки под вашу структуру базы данных.
"""
print("--- Режим работы с реальной базой данных ---")
selected_index = self.project_combobox.current()
if selected_index == -1:
print("Ошибка: Не выбран проект.")
return None
projects = self.root_window.project_manager.list_projects()
try:
selected_project_id = projects[selected_index].id
except IndexError:
print("Ошибка: Проект не найден в списке.")
return None
try:
# --- ИСПРАВЛЕННЫЙ БЛОК ---
# Весь код внутри этого 'try' должен иметь одинаковый отступ
conn = sqlite3.connect(f"project_dbs/{selected_project_id}.db")
cursor = conn.cursor()
cursor.execute("""
SELECT event_sign FROM project_data WHERE project_code = ?
""", (selected_project_id,))
rows = cursor.fetchall()
conn.close()
delta_history = [row[0] for row in rows if row[0] is not None]
print(f"Динамика из БД: {delta_history}")
return {"base_level": 0, "delta_history": delta_history}
# --- КОНЕЦ ИСПРАВЛЕННОГО БЛОКА ---
except Exception as e:
# Блок 'except' находится на том же уровне, что и 'try'
print(f"Ошибка при работе с базой данных: {e}")
return None
def update_graph(self, result_data):
"""
Обновляет график на основе переданных данных.
"""
lower_bound = result_data['lower_bound']
median = result_data['median']
upper_bound = result_data['upper_bound']
base_level = result_data['base_level']
trajectory = result_data['delta_history']
# Очищаем график
self.ax.clear()
# Рисуем горизонтальные линии границ и медианы
if lower_bound is not None:
self.ax.axhline(y=lower_bound, color='red', linestyle='--', label=f'Нижняя граница')
if median is not None:
self.ax.axhline(y=median , color='green', linestyle='-', label=f'Медиана')
if upper_bound is not None:
self.ax.axhline(y=upper_bound , color='blue', linestyle='--', label=f'Верхняя граница')
# Рисуем траекторию (линию графика), если есть данные
if trajectory and len(trajectory) > 1:
from datetime import datetime, timedelta
start_date = datetime.now().date()
step_days = int(self.step_entry.get())
dates_for_plot = []
for i in range(len(trajectory)):
day_offset = i * step_days
plot_date = start_date + timedelta(days=day_offset)
dates_for_plot.append(plot_date)
# Сама линия траектории
self.ax.plot(dates_for_plot, trajectory,
color='orange', marker='o',
linewidth=2, markersize=6,
label='Смоделированная динамика')
# Настраиваем ось X с датами
self.ax.set_xticks(dates_for_plot)
self.ax.set_xticklabels([d.strftime('%d.%m') for d in dates_for_plot])
# Настраиваем внешний вид графика
self.ax.set_ylabel('Накопленное значение')
self.ax.set_xlabel('Дата')
self.ax.grid(True, linestyle='--', alpha=0.7)
self.ax.legend(loc='upper left')
# Перерисовываем canvas ОЧЕНЬ ВАЖНО!
self.canvas.draw()