385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""
|
|
Streamlit Dashboard for Brazilian Air Force Fleet Assignment.
|
|
Provides interactive mapping, scenario generation, and SCIP solver integration.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import sqlite3
|
|
import subprocess
|
|
import datetime
|
|
import pandas as pd
|
|
import pydeck as pdk
|
|
import streamlit as st
|
|
from vincenty import vincenty
|
|
|
|
# ==========================================
|
|
# PAGE CONFIGURATION
|
|
# ==========================================
|
|
st.set_page_config(
|
|
page_title="Fleet Assignment",
|
|
page_icon="✈️",
|
|
layout="wide",
|
|
initial_sidebar_state="expanded"
|
|
)
|
|
|
|
# ==========================================
|
|
# CONSTANTS & CONFIGURATION
|
|
# ==========================================
|
|
import sys
|
|
# Ensure we can import from src
|
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from src.fleet_assignment.config import (
|
|
CSV_FILEPATH, JSON_AEROPORTOS, JSON_EMISSORES, DB_PATH,
|
|
RESULTS_FILE, COST_FILE, AIRCRAFT_CONFIG
|
|
)
|
|
|
|
from src.fleet_assignment.ingest import load_data
|
|
|
|
@st.cache_data
|
|
def get_cached_data():
|
|
return load_data()
|
|
|
|
demands_raw, df_fleet, valid_icaos, airports_geo = get_cached_data()
|
|
|
|
# ==========================================
|
|
# DATABASE MANAGEMENT
|
|
# ==========================================
|
|
def execute_sql(query, params=(), fetch=False):
|
|
"""Helper to execute SQL commands cleanly."""
|
|
with sqlite3.connect(DB_PATH) as conn:
|
|
if fetch:
|
|
return pd.read_sql_query(query, conn, params=params)
|
|
conn.execute(query, params)
|
|
conn.commit()
|
|
|
|
def init_db(demands_df, frota_df, force_reset=False):
|
|
"""Initializes SQLite Database as the single source of truth."""
|
|
if force_reset and os.path.exists(DB_PATH):
|
|
os.remove(DB_PATH)
|
|
|
|
with sqlite3.connect(DB_PATH) as conn:
|
|
conn.execute('''
|
|
CREATE TABLE IF NOT EXISTS demandas (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
data_apenas TEXT,
|
|
local_dec TEXT,
|
|
local_pou TEXT,
|
|
pax INTEGER
|
|
)
|
|
''')
|
|
|
|
count = conn.execute("SELECT COUNT(*) FROM demandas").fetchone()[0]
|
|
if count == 0:
|
|
df_insert = demands_df[['Data Apenas', 'Localidade Decolagem', 'Localidade Pouso', 'PAX']].copy()
|
|
df_insert.columns = ['data_apenas', 'local_dec', 'local_pou', 'pax']
|
|
df_insert.to_sql('demandas', conn, if_exists='append', index=False)
|
|
frota_df.to_sql('frota', conn, if_exists='replace', index=False)
|
|
|
|
def load_demands():
|
|
return execute_sql("SELECT id as ID, data_apenas as 'Date', local_dec as 'Dep', local_pou as 'Arr', pax as 'PAX' FROM demandas", fetch=True)
|
|
|
|
init_db(demands_raw, df_fleet)
|
|
|
|
@st.cache_data
|
|
def load_results():
|
|
try:
|
|
return pd.read_csv(RESULTS_FILE, sep=';')
|
|
except FileNotFoundError:
|
|
return pd.DataFrame()
|
|
|
|
df_results = load_results()
|
|
|
|
# ==========================================
|
|
# SOLVER INTEGRATION
|
|
# ==========================================
|
|
def run_solver_subprocess(time_limit_sec, output_placeholder):
|
|
"""Spawns the optimization worker and streams logs to the UI."""
|
|
process = subprocess.Popen(
|
|
["uv", "run", "python", "-u", "src/fleet_assignment/optimizer.py", str(time_limit_sec)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
full_log = ""
|
|
for line in iter(process.stdout.readline, ''):
|
|
full_log += line
|
|
output_placeholder.code(full_log, language='text')
|
|
|
|
process.wait()
|
|
return process.returncode == 0
|
|
|
|
# ==========================================
|
|
# UI RENDER
|
|
# ==========================================
|
|
st.title("✈️ Fleet Assignment")
|
|
st.markdown("Brazilian Air Force Fleet Routing and Scheduling (Global Time-Space Network Flow)")
|
|
|
|
tab1, tab2, tab3, tab4, tab5 = st.tabs(["Demand Data", "Fleet", "Solver", "Map", "Results"])
|
|
|
|
# --- TAB 1: DEMAND DATA ---
|
|
with tab1:
|
|
col_title, col_reset = st.columns([0.8, 0.2])
|
|
with col_title:
|
|
st.header("Passenger Demands")
|
|
with col_reset:
|
|
if st.button("🔄 Reset DB to Raw CSV", type="secondary", width="stretch"):
|
|
init_db(demands_raw, df_fleet, force_reset=True)
|
|
st.success("Database Reset!")
|
|
st.rerun()
|
|
|
|
df_display_demand = load_demands()
|
|
|
|
st.info("💡 You can edit, add, or delete rows directly in the table below. Click 'Save Changes' when done.")
|
|
edited_demand = st.data_editor(
|
|
df_display_demand,
|
|
num_rows="dynamic",
|
|
width="stretch",
|
|
hide_index=True,
|
|
key="demand_editor"
|
|
)
|
|
|
|
if st.button("💾 Save Demand Changes", type="primary", width="stretch"):
|
|
with sqlite3.connect(DB_PATH) as conn:
|
|
conn.execute("DROP TABLE IF EXISTS demandas")
|
|
conn.execute('''
|
|
CREATE TABLE demandas (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
data_apenas TEXT,
|
|
local_dec TEXT,
|
|
local_pou TEXT,
|
|
pax INTEGER
|
|
)
|
|
''')
|
|
df_to_save = edited_demand.drop(columns=['ID'], errors='ignore')
|
|
df_to_save.columns = ['data_apenas', 'local_dec', 'local_pou', 'pax']
|
|
df_to_save.to_sql('demandas', conn, if_exists='append', index=False)
|
|
st.success("Database synchronized successfully!")
|
|
st.rerun()
|
|
|
|
# --- TAB 2: FLEET ---
|
|
with tab2:
|
|
st.header("Fleet Specifications")
|
|
|
|
# Check if we have modified fleet in DB, otherwise use default
|
|
with sqlite3.connect(DB_PATH) as conn:
|
|
try:
|
|
current_fleet = pd.read_sql_query("SELECT * FROM frota", conn)
|
|
except Exception:
|
|
current_fleet = df_fleet.copy()
|
|
|
|
df_display_fleet = current_fleet[['Modelo', 'Emissor', 'Tamanho da Frota', 'Consumo (km/l)', 'Velocidade Média (km/h)', 'Capacidade', 'Alcance']].copy()
|
|
df_display_fleet.columns = ['Modelo', 'Squadron', 'Fleet Size', 'Fuel Usage (km/l)', 'Mean Speed (km/h)', 'PAX Capacity', 'Max Range (km)']
|
|
|
|
st.info("💡 Edit specs directly below to simulate larger fleets, different fuel burn, or cargo capacity.")
|
|
edited_fleet = st.data_editor(
|
|
df_display_fleet,
|
|
width="stretch",
|
|
hide_index=True,
|
|
key="fleet_editor"
|
|
)
|
|
|
|
if st.button("💾 Save Fleet Changes", type="primary", width="stretch"):
|
|
rename_back = {
|
|
'Modelo': 'Modelo', 'Squadron': 'Emissor', 'Fleet Size': 'Tamanho da Frota',
|
|
'Fuel Usage (km/l)': 'Consumo (km/l)', 'Mean Speed (km/h)': 'Velocidade Média (km/h)',
|
|
'PAX Capacity': 'Capacidade', 'Max Range (km)': 'Alcance'
|
|
}
|
|
df_save_fleet = edited_fleet.rename(columns=rename_back)
|
|
|
|
with sqlite3.connect(DB_PATH) as conn:
|
|
old_frota = pd.read_sql_query("SELECT * FROM frota", conn)
|
|
for idx, row in df_save_fleet.iterrows():
|
|
mask = (old_frota['Modelo'] == row['Modelo']) & (old_frota['Emissor'] == row['Emissor'])
|
|
old_frota.loc[mask, 'Tamanho da Frota'] = row['Tamanho da Frota']
|
|
old_frota.loc[mask, 'Consumo (km/l)'] = row['Consumo (km/l)']
|
|
old_frota.loc[mask, 'Velocidade Média (km/h)'] = row['Velocidade Média (km/h)']
|
|
old_frota.loc[mask, 'Capacidade'] = row['Capacidade']
|
|
old_frota.loc[mask, 'Alcance'] = row['Alcance']
|
|
old_frota.to_sql('frota', conn, if_exists='replace', index=False)
|
|
|
|
st.success("Fleet changes saved successfully!")
|
|
st.rerun()
|
|
|
|
# --- TAB 3: SOLVER ---
|
|
with tab3:
|
|
st.header("Mathematical Formulation")
|
|
st.latex(r"\min \sum_{m,e,r,t} (c_{m,e,r} \cdot x_{m,e,r,t})")
|
|
st.latex(r"\text{s.t.} \quad \sum_{m,e} (\text{cap}_{m} \cdot x_{m,e,r,t}) \ge \text{PAX}_{r,t} \quad \forall r, t")
|
|
st.latex(r"\sum_{t_{start} \le T \le t_{start} + \text{overnights}} x_{m,e,r,t_{start}} \le \text{MaxFleet}_{m,e} \quad \forall m, e, T")
|
|
st.latex(r"\text{FlightTime}_{m,r} \cdot x_{m,e,r,t} \le \text{MaxDailyHours} \quad \forall m,e,r,t")
|
|
st.latex(r"\text{MissionTime}_{m,r} \le \text{MaxDaysOut} \quad \forall m,r")
|
|
|
|
st.markdown(r"""
|
|
**Dictionary of Variables & Constraints:**
|
|
- $x_{m,e,r,t}$: Decision Variable (Integer). Number of aircraft of model $m$, from squadron $e$, allocated to route $r$ on day $t$.
|
|
- $c_{m,e,r}$: Total fuel cost function: $c_{m,e,r} = \left( \frac{\text{Dist}_r \cdot \text{RangePenalty}}{\text{FuelConsumption}_m} \right) + (\text{overnights} \cdot \text{DailyPenalty}_m)$.
|
|
- $\text{RangePenalty}$: Applies a **25% penalty** multiplier ($1.25$) to the fuel burn if any leg of the mission exceeds the aircraft's maximum range.
|
|
- $\text{DailyPenalty}_m$: Equivalent to an extra daily fuel expenditure quota for each day the aircraft spends out of its base.
|
|
- $\text{cap}_m$: Maximum passenger capacity (seats) of aircraft $m$.
|
|
- $\text{PAX}_{r,t}$: Actual number of Air Force passengers needing to fly on route $r$ on day $t$.
|
|
- $\text{MaxFleet}_{m,e}$: Total physical aircraft available in squadron $e$ for model $m$.
|
|
- $T$: Temporal inspection window. Ensures aircraft blockade during overnight stays, preventing fleet duplication.
|
|
- $\text{MaxDailyHours}$: Maximum allowed flight time per day per aircraft (typically $12$ hours).
|
|
- $\text{MaxDaysOut}$: Maximum allowable time for a mission before returning to base (hard limit of $96$ hours / 4 days).
|
|
""")
|
|
|
|
if st.button("🚀 Run Global Optimization", type="primary"):
|
|
st.info("Starting Worker... Follow the real-time SCIP log below:")
|
|
log_placeholder = st.empty()
|
|
|
|
if run_solver_subprocess(300, log_placeholder):
|
|
st.success("Solver Finished! Results saved to CSV.")
|
|
load_results.clear()
|
|
st.rerun()
|
|
else:
|
|
st.error("Solver Failed or was aborted.")
|
|
|
|
# --- TAB 4: MAP ---
|
|
with tab4:
|
|
st.header("Interactive Flight Map")
|
|
|
|
view_state = pdk.ViewState(latitude=-15.78, longitude=-47.92, zoom=3.5, pitch=45)
|
|
layers = []
|
|
|
|
if df_results.empty:
|
|
st.info("No optimization results yet. Run the Global Optimization to see routes.")
|
|
else:
|
|
df_results['Data'] = pd.to_datetime(df_results['Data'])
|
|
dias_disponiveis = sorted(df_results['Data'].dt.date.unique())
|
|
|
|
# Safe initialization of map date
|
|
if 'current_map_date' not in st.session_state or st.session_state.current_map_date not in dias_disponiveis:
|
|
st.session_state.current_map_date = dias_disponiveis[0]
|
|
|
|
current_idx = dias_disponiveis.index(st.session_state.current_map_date)
|
|
|
|
# Navigation Controls
|
|
col_prev, col_drop, col_next = st.columns([1, 8, 1])
|
|
with col_prev:
|
|
st.markdown("<br/>", unsafe_allow_html=True)
|
|
if st.button("⬅️", disabled=current_idx==0, width="stretch"):
|
|
st.session_state.current_map_date = dias_disponiveis[current_idx - 1]
|
|
st.rerun()
|
|
with col_drop:
|
|
selected_date = st.date_input("Select via Calendar", value=st.session_state.current_map_date)
|
|
if selected_date != st.session_state.current_map_date:
|
|
st.session_state.current_map_date = selected_date
|
|
st.rerun()
|
|
with col_next:
|
|
st.markdown("<br/>", unsafe_allow_html=True)
|
|
if st.button("➡️", disabled=current_idx>=len(dias_disponiveis)-1, width="stretch"):
|
|
st.session_state.current_map_date = dias_disponiveis[current_idx + 1]
|
|
st.rerun()
|
|
|
|
slider_date = st.select_slider("Or scrub through scheduled flight days", options=dias_disponiveis, value=st.session_state.current_map_date)
|
|
if slider_date != st.session_state.current_map_date:
|
|
st.session_state.current_map_date = slider_date
|
|
st.rerun()
|
|
|
|
df_day = df_results[df_results['Data'].dt.date == st.session_state.current_map_date]
|
|
df_demands_map = load_demands()
|
|
|
|
# Map Data Parsing
|
|
map_data = []
|
|
for _, row in df_day.iterrows():
|
|
orig, dest = row['Local Decolagem'], row['Local Pouso']
|
|
|
|
if orig in airports_geo and dest in airports_geo:
|
|
lon1, lat1 = float(airports_geo[orig]['lon']), float(airports_geo[orig]['lat'])
|
|
lon2, lat2 = float(airports_geo[dest]['lon']), float(airports_geo[dest]['lat'])
|
|
|
|
pernoites = row.get('Pernoites', 0)
|
|
color = [255, 65, 54, 200] if pernoites > 0 else [0, 116, 217, 200]
|
|
|
|
# Calculate PAX sum for this route on this day
|
|
pax_mask = (df_demands_map['Dep'] == orig) & (df_demands_map['Arr'] == dest) & (df_demands_map['Date'] == str(st.session_state.current_map_date))
|
|
pax_count = df_demands_map[pax_mask]['PAX'].sum()
|
|
|
|
map_data.append({
|
|
"start": [lon1, lat1], "end": [lon2, lat2], "color": color,
|
|
"modelo": row['Modelo'], "orig": orig, "dest": dest,
|
|
"pax": int(pax_count), "pernoites": pernoites
|
|
})
|
|
|
|
df_map = pd.DataFrame(map_data)
|
|
if df_map.empty:
|
|
st.info("No flights scheduled for this day.")
|
|
else:
|
|
layers.append(pdk.Layer(
|
|
"ArcLayer", data=df_map,
|
|
get_source_position="start", get_target_position="end",
|
|
get_source_color="color", get_target_color="color",
|
|
get_width=3, pickable=True
|
|
))
|
|
|
|
st.pydeck_chart(pdk.Deck(
|
|
layers=layers,
|
|
initial_view_state=view_state,
|
|
tooltip={
|
|
"html": "<b>✈️ Aircraft:</b> {modelo}<br/><b>📍 Route:</b> {orig} ➔ {dest}<br/><b>👥 PAX:</b> {pax}<br/><b>🌙 Out of Base:</b> {pernoites} days",
|
|
"style": {"backgroundColor": "steelblue", "color": "white"}
|
|
} if layers else None,
|
|
map_style='road'
|
|
))
|
|
|
|
# Selected Day Results Table
|
|
if not df_results.empty:
|
|
df_day_display = df_day.copy()
|
|
|
|
# Bind PAX back into the table
|
|
df_day_display['PAX'] = df_day_display.apply(lambda r: df_demands_map[
|
|
(df_demands_map['Dep'] == r['Local Decolagem']) &
|
|
(df_demands_map['Arr'] == r['Local Pouso']) &
|
|
(df_demands_map['Date'] == str(st.session_state.current_map_date))
|
|
]['PAX'].sum(), axis=1)
|
|
|
|
df_day_display.rename(columns={
|
|
'Data': 'Date', 'Modelo': 'Model', 'Emissor': 'Squadron',
|
|
'Local Decolagem': 'Departure', 'Local Pouso': 'Arrival',
|
|
'Qtd Aeronaves': 'Allocated Fleet', 'Pernoites': 'Days Out of Base'
|
|
}, inplace=True)
|
|
|
|
st.subheader(f"Flight Schedule for {st.session_state.current_map_date}")
|
|
st.dataframe(df_day_display, width="stretch")
|
|
|
|
# --- TAB 5: RESULTS ---
|
|
with tab5:
|
|
st.header("Optimization Results")
|
|
|
|
try:
|
|
raw_fuel_df = execute_sql("SELECT SUM(\"Combustível\") as total FROM frota", fetch=True)
|
|
raw_fuel = raw_fuel_df['total'].iloc[0] if not raw_fuel_df.empty else 0
|
|
if pd.isna(raw_fuel): raw_fuel = 0
|
|
|
|
opt_fuel = None
|
|
if os.path.exists(COST_FILE):
|
|
with open(COST_FILE, "r") as f:
|
|
opt_fuel = float(f.read().strip())
|
|
|
|
colA, colB, colC = st.columns(3)
|
|
with colA:
|
|
st.metric(label="Baseline Fuel (Non-Optimized)", value=f"{raw_fuel:,.2f} L")
|
|
with colB:
|
|
if opt_fuel is not None:
|
|
st.metric(label="Optimized Fuel Consumption", value=f"{opt_fuel:,.2f} L", delta=f"{opt_fuel - raw_fuel:,.2f} L", delta_color="inverse")
|
|
with colC:
|
|
if opt_fuel is not None and raw_fuel > 0:
|
|
savings = (1 - (opt_fuel / raw_fuel)) * 100
|
|
st.metric(label="Total Fuel Savings", value=f"{savings:.2f}%")
|
|
except Exception:
|
|
pass
|
|
|
|
if not df_results.empty:
|
|
st.dataframe(df_results, width="stretch")
|
|
|
|
if 'Pernoites' in df_results.columns:
|
|
st.subheader("Days Out of Base (Pernoites) Breakdown")
|
|
df_pernoites = df_results[df_results['Pernoites'] > 0]
|
|
st.dataframe(df_pernoites[['Modelo', 'Local Decolagem', 'Local Pouso', 'Pernoites']].sort_values(by='Pernoites', ascending=False))
|
|
else:
|
|
st.warning("No results available.")
|