Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41d91d9c30 | |||
| 14d9943665 | |||
| 13a4826415 |
+161
-55
@@ -9,7 +9,7 @@ from pathology_manager import PathologyManager
|
|||||||
|
|
||||||
|
|
||||||
class DataManager:
|
class DataManager:
|
||||||
"""Handle all data operations for the application."""
|
"""Handle all data operations for the application with performance optimizations."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -22,10 +22,21 @@ class DataManager:
|
|||||||
self.logger: logging.Logger = logger
|
self.logger: logging.Logger = logger
|
||||||
self.medicine_manager = medicine_manager
|
self.medicine_manager = medicine_manager
|
||||||
self.pathology_manager = pathology_manager
|
self.pathology_manager = pathology_manager
|
||||||
|
|
||||||
|
# Cache for loaded data to avoid repeated file I/O
|
||||||
|
self._data_cache: pd.DataFrame | None = None
|
||||||
|
self._cache_timestamp: float = 0
|
||||||
|
self._headers_cache: tuple[str, ...] | None = None
|
||||||
|
self._dtype_cache: dict[str, type] | None = None
|
||||||
|
|
||||||
self._initialize_csv_file()
|
self._initialize_csv_file()
|
||||||
|
|
||||||
def _get_csv_headers(self) -> list[str]:
|
def _get_csv_headers(self) -> tuple[str, ...]:
|
||||||
"""Get CSV headers based on current pathology and medicine configuration."""
|
"""Get CSV headers based on current pathology and medicine configuration.
|
||||||
|
Cached to avoid repeated computation."""
|
||||||
|
if self._headers_cache is not None:
|
||||||
|
return self._headers_cache
|
||||||
|
|
||||||
# Start with date
|
# Start with date
|
||||||
headers = ["date"]
|
headers = ["date"]
|
||||||
|
|
||||||
@@ -37,7 +48,9 @@ class DataManager:
|
|||||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
headers.extend([medicine_key, f"{medicine_key}_doses"])
|
headers.extend([medicine_key, f"{medicine_key}_doses"])
|
||||||
|
|
||||||
return headers + ["note"]
|
result = tuple(headers + ["note"])
|
||||||
|
self._headers_cache = result
|
||||||
|
return result
|
||||||
|
|
||||||
def _initialize_csv_file(self) -> None:
|
def _initialize_csv_file(self) -> None:
|
||||||
"""Create CSV file with headers if it doesn't exist or is empty."""
|
"""Create CSV file with headers if it doesn't exist or is empty."""
|
||||||
@@ -46,27 +59,74 @@ class DataManager:
|
|||||||
writer = csv.writer(file)
|
writer = csv.writer(file)
|
||||||
writer.writerow(self._get_csv_headers())
|
writer.writerow(self._get_csv_headers())
|
||||||
|
|
||||||
|
def _invalidate_cache(self) -> None:
|
||||||
|
"""Invalidate the data cache when data changes."""
|
||||||
|
self._data_cache = None
|
||||||
|
self._cache_timestamp = 0
|
||||||
|
|
||||||
|
def _should_reload_data(self) -> bool:
|
||||||
|
"""Check if data should be reloaded based on file modification time."""
|
||||||
|
if self._data_cache is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_mtime = os.path.getmtime(self.filename)
|
||||||
|
return file_mtime > self._cache_timestamp
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_dtype_dict(self) -> dict[str, type]:
|
||||||
|
"""Get pandas dtype dictionary for efficient reading.
|
||||||
|
Cached to avoid recreation."""
|
||||||
|
if self._dtype_cache is not None:
|
||||||
|
return self._dtype_cache
|
||||||
|
|
||||||
|
dtype_dict = {"date": str, "note": str}
|
||||||
|
|
||||||
|
# Add pathology types
|
||||||
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
|
dtype_dict[pathology_key] = int
|
||||||
|
|
||||||
|
# Add medicine types
|
||||||
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
|
dtype_dict[medicine_key] = int
|
||||||
|
dtype_dict[f"{medicine_key}_doses"] = str
|
||||||
|
|
||||||
|
self._dtype_cache = dtype_dict
|
||||||
|
return dtype_dict
|
||||||
|
|
||||||
def load_data(self) -> pd.DataFrame:
|
def load_data(self) -> pd.DataFrame:
|
||||||
"""Load data from CSV file."""
|
"""Load data from CSV file with caching for better performance."""
|
||||||
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
|
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
|
||||||
self.logger.warning("CSV file is empty or doesn't exist. No data to load.")
|
self.logger.warning("CSV file is empty or doesn't exist. No data to load.")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Use cached data if available and file hasn't changed
|
||||||
|
if not self._should_reload_data():
|
||||||
|
return self._data_cache.copy()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build dtype dictionary dynamically
|
# Use pre-built dtype dictionary for faster parsing
|
||||||
dtype_dict = {"date": str, "note": str}
|
dtype_dict = self._get_dtype_dict()
|
||||||
|
|
||||||
# Add pathology types
|
# Read with optimized settings
|
||||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
df: pd.DataFrame = pd.read_csv(
|
||||||
dtype_dict[pathology_key] = int
|
self.filename,
|
||||||
|
dtype=dtype_dict,
|
||||||
|
na_filter=False, # Don't convert to NaN, keep as empty strings
|
||||||
|
engine="c", # Use faster C engine
|
||||||
|
)
|
||||||
|
|
||||||
# Add medicine types
|
# Sort only if needed (check if already sorted)
|
||||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
if len(df) > 1 and not df["date"].is_monotonic_increasing:
|
||||||
dtype_dict[medicine_key] = int
|
df = df.sort_values(by="date").reset_index(drop=True)
|
||||||
dtype_dict[f"{medicine_key}_doses"] = str
|
|
||||||
|
# Cache the data and timestamp
|
||||||
|
self._data_cache = df.copy()
|
||||||
|
self._cache_timestamp = os.path.getmtime(self.filename)
|
||||||
|
|
||||||
|
return df.copy()
|
||||||
|
|
||||||
df: pd.DataFrame = pd.read_csv(self.filename, dtype=dtype_dict).fillna("")
|
|
||||||
return df.sort_values(by="date").reset_index(drop=True)
|
|
||||||
except pd.errors.EmptyDataError:
|
except pd.errors.EmptyDataError:
|
||||||
self.logger.warning("CSV file is empty. No data to load.")
|
self.logger.warning("CSV file is empty. No data to load.")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -75,69 +135,104 @@ class DataManager:
|
|||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
def add_entry(self, entry_data: list[str | int]) -> bool:
|
def add_entry(self, entry_data: list[str | int]) -> bool:
|
||||||
"""Add a new entry to the CSV file."""
|
"""Add a new entry to the CSV file with optimized duplicate checking."""
|
||||||
try:
|
try:
|
||||||
# Check if date already exists
|
# Quick duplicate check using cached data if available
|
||||||
df: pd.DataFrame = self.load_data()
|
|
||||||
date_to_add: str = str(entry_data[0])
|
date_to_add: str = str(entry_data[0])
|
||||||
|
|
||||||
if not df.empty and date_to_add in df["date"].values:
|
if self._data_cache is not None:
|
||||||
self.logger.warning(f"Entry with date {date_to_add} already exists.")
|
# Use cached data for duplicate check
|
||||||
return False
|
if date_to_add in self._data_cache["date"].values:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Entry with date {date_to_add} already exists."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Fallback to loading data if no cache
|
||||||
|
df: pd.DataFrame = self.load_data()
|
||||||
|
if not df.empty and date_to_add in df["date"].values:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Entry with date {date_to_add} already exists."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Write to file
|
||||||
with open(self.filename, mode="a", newline="") as file:
|
with open(self.filename, mode="a", newline="") as file:
|
||||||
writer = csv.writer(file)
|
writer = csv.writer(file)
|
||||||
writer.writerow(entry_data)
|
writer.writerow(entry_data)
|
||||||
|
|
||||||
|
# Invalidate cache since data changed
|
||||||
|
self._invalidate_cache()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error adding entry: {str(e)}")
|
self.logger.error(f"Error adding entry: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_entry(self, original_date: str, values: list[str | int]) -> bool:
|
def update_entry(self, original_date: str, values: list[str | int]) -> bool:
|
||||||
"""Update an existing entry identified by original_date."""
|
"""Update an existing entry identified by original_date
|
||||||
|
with optimized processing."""
|
||||||
try:
|
try:
|
||||||
df: pd.DataFrame = self.load_data()
|
df: pd.DataFrame = self.load_data()
|
||||||
new_date: str = str(values[0])
|
new_date: str = str(values[0])
|
||||||
|
|
||||||
# If the date is being changed, check if the new date already exists
|
# Optimized duplicate check
|
||||||
if original_date != new_date and new_date in df["date"].values:
|
if original_date != new_date:
|
||||||
|
date_exists = (df["date"] == new_date).any()
|
||||||
|
if date_exists:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Cannot update: entry with date {new_date} already exists."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get current CSV headers to match with values
|
||||||
|
headers = list(self._get_csv_headers())
|
||||||
|
|
||||||
|
# Ensure we have the right number of values with optimized padding
|
||||||
|
if len(values) < len(headers):
|
||||||
|
# Pad with defaults efficiently
|
||||||
|
padding_needed = len(headers) - len(values)
|
||||||
|
for i in range(padding_needed):
|
||||||
|
header_idx = len(values) + i
|
||||||
|
if header_idx < len(headers):
|
||||||
|
header = headers[header_idx]
|
||||||
|
if header == "note" or header.endswith("_doses"):
|
||||||
|
values.append("")
|
||||||
|
else:
|
||||||
|
values.append(0)
|
||||||
|
|
||||||
|
# Use vectorized update for better performance
|
||||||
|
mask = df["date"] == original_date
|
||||||
|
if mask.any():
|
||||||
|
df.loc[mask, headers] = values
|
||||||
|
# Write back to CSV with optimized method
|
||||||
|
df.to_csv(self.filename, index=False, mode="w")
|
||||||
|
self._invalidate_cache()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"Cannot update: entry with date {new_date} already exists."
|
f"Entry with date {original_date} not found for update."
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get current CSV headers to match with values
|
|
||||||
headers = self._get_csv_headers()
|
|
||||||
|
|
||||||
# Ensure we have the right number of values
|
|
||||||
if len(values) != len(headers):
|
|
||||||
self.logger.warning(
|
|
||||||
f"Value count mismatch: expected {len(headers)}, got {len(values)}"
|
|
||||||
)
|
|
||||||
# Pad with defaults if too few values
|
|
||||||
while len(values) < len(headers):
|
|
||||||
header = headers[len(values)]
|
|
||||||
if header == "note" or header.endswith("_doses"):
|
|
||||||
values.append("")
|
|
||||||
else:
|
|
||||||
values.append(0)
|
|
||||||
|
|
||||||
# Update the row using column names
|
|
||||||
df.loc[df["date"] == original_date, headers] = values
|
|
||||||
df.to_csv(self.filename, index=False)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error updating entry: {str(e)}")
|
self.logger.error(f"Error updating entry: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_entry(self, date: str) -> bool:
|
def delete_entry(self, date: str) -> bool:
|
||||||
"""Delete an entry identified by date."""
|
"""Delete an entry identified by date with optimized processing."""
|
||||||
try:
|
try:
|
||||||
df: pd.DataFrame = self.load_data()
|
df: pd.DataFrame = self.load_data()
|
||||||
# Remove the row with the matching date
|
original_len = len(df)
|
||||||
|
|
||||||
|
# Use vectorized filtering for better performance
|
||||||
df = df[df["date"] != date]
|
df = df[df["date"] != date]
|
||||||
# Write the updated dataframe back to the CSV
|
|
||||||
df.to_csv(self.filename, index=False)
|
# Only write if something was actually deleted
|
||||||
|
if len(df) < original_len:
|
||||||
|
df.to_csv(self.filename, index=False, mode="w")
|
||||||
|
self._invalidate_cache()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error deleting entry: {str(e)}")
|
self.logger.error(f"Error deleting entry: {str(e)}")
|
||||||
@@ -146,23 +241,34 @@ class DataManager:
|
|||||||
def get_today_medicine_doses(
|
def get_today_medicine_doses(
|
||||||
self, date: str, medicine_name: str
|
self, date: str, medicine_name: str
|
||||||
) -> list[tuple[str, str]]:
|
) -> list[tuple[str, str]]:
|
||||||
"""Get list of (timestamp, dose) tuples for a medicine on a given date."""
|
"""Get list of (timestamp, dose) tuples for a medicine on a given date
|
||||||
|
with caching."""
|
||||||
try:
|
try:
|
||||||
df: pd.DataFrame = self.load_data()
|
df: pd.DataFrame = self.load_data()
|
||||||
if df.empty or date not in df["date"].values:
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use vectorized filtering for better performance
|
||||||
|
date_mask = df["date"] == date
|
||||||
|
if not date_mask.any():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
dose_column = f"{medicine_name}_doses"
|
dose_column = f"{medicine_name}_doses"
|
||||||
doses_str = df.loc[df["date"] == date, dose_column].iloc[0]
|
if dose_column not in df.columns:
|
||||||
|
return []
|
||||||
|
|
||||||
|
doses_str = df.loc[date_mask, dose_column].iloc[0]
|
||||||
|
|
||||||
if not doses_str:
|
if not doses_str:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Optimized dose parsing
|
||||||
doses = []
|
doses = []
|
||||||
for dose_entry in doses_str.split("|"):
|
for dose_entry in doses_str.split("|"):
|
||||||
if ":" in dose_entry:
|
if ":" in dose_entry:
|
||||||
timestamp, dose = dose_entry.split(":", 1)
|
parts = dose_entry.split(":", 1)
|
||||||
doses.append((timestamp, dose))
|
if len(parts) == 2:
|
||||||
|
doses.append((parts[0], parts[1]))
|
||||||
|
|
||||||
return doses
|
return doses
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+221
-169
@@ -12,7 +12,8 @@ from pathology_manager import PathologyManager
|
|||||||
|
|
||||||
|
|
||||||
class GraphManager:
|
class GraphManager:
|
||||||
"""Handle all graph-related operations for the application."""
|
"""Optimized version - Handle all graph-related operations for the
|
||||||
|
application with performance improvements."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -24,166 +25,206 @@ class GraphManager:
|
|||||||
self.medicine_manager = medicine_manager
|
self.medicine_manager = medicine_manager
|
||||||
self.pathology_manager = pathology_manager
|
self.pathology_manager = pathology_manager
|
||||||
|
|
||||||
# Configure graph frame to expand
|
# Initialize matplotlib with optimized settings
|
||||||
self.parent_frame.grid_rowconfigure(0, weight=1)
|
self.fig: matplotlib.figure.Figure = plt.figure(figsize=(10, 6), dpi=80)
|
||||||
self.parent_frame.grid_columnconfigure(0, weight=1)
|
self.ax: Axes = self.fig.add_subplot(111)
|
||||||
|
|
||||||
self._initialize_toggle_vars()
|
# Cache for current data to avoid reprocessing
|
||||||
|
self.current_data: pd.DataFrame = pd.DataFrame()
|
||||||
|
self._last_plot_hash: str = ""
|
||||||
|
|
||||||
|
# Initialize UI components
|
||||||
|
self.toggle_vars: dict[str, tk.IntVar] = {}
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
self._initialize_toggle_vars()
|
||||||
def _initialize_toggle_vars(self) -> None:
|
|
||||||
"""Initialize toggle variables for chart elements."""
|
|
||||||
self.toggle_vars: dict[str, tk.BooleanVar] = {}
|
|
||||||
|
|
||||||
# Initialize pathology toggles dynamically
|
|
||||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
|
||||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
|
||||||
default_value = pathology.default_enabled if pathology else True
|
|
||||||
self.toggle_vars[pathology_key] = tk.BooleanVar(value=default_value)
|
|
||||||
|
|
||||||
# Add medicine toggles dynamically
|
|
||||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
|
||||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
|
||||||
default_value = medicine.default_enabled if medicine else False
|
|
||||||
self.toggle_vars[medicine_key] = tk.BooleanVar(value=default_value)
|
|
||||||
|
|
||||||
def _setup_ui(self) -> None:
|
|
||||||
"""Set up the UI components."""
|
|
||||||
# Create control frame for toggles
|
|
||||||
self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
|
||||||
self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
|
||||||
|
|
||||||
# Create toggle checkboxes
|
|
||||||
self._create_chart_toggles()
|
self._create_chart_toggles()
|
||||||
|
|
||||||
# Create graph frame
|
def _initialize_toggle_vars(self) -> None:
|
||||||
self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
"""Initialize toggle variables for chart elements with optimization."""
|
||||||
self.graph_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
|
# Initialize pathology toggles
|
||||||
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
|
self.toggle_vars[pathology_key] = tk.IntVar(value=1)
|
||||||
|
|
||||||
# Reconfigure parent frame for new layout
|
# Initialize medicine toggles (unchecked by default)
|
||||||
self.parent_frame.grid_rowconfigure(1, weight=1)
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
self.parent_frame.grid_columnconfigure(0, weight=1)
|
self.toggle_vars[medicine_key] = tk.IntVar(value=0)
|
||||||
|
|
||||||
# Initialize matplotlib figure and canvas
|
def _setup_ui(self) -> None:
|
||||||
self.fig: matplotlib.figure.Figure
|
"""Set up the UI components with performance optimizations."""
|
||||||
self.ax: Axes
|
# Create canvas with optimized settings
|
||||||
self.fig, self.ax = plt.subplots()
|
self.canvas = FigureCanvasTkAgg(self.fig, master=self.parent_frame)
|
||||||
self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg(
|
self.canvas.draw_idle() # Use draw_idle for better performance
|
||||||
figure=self.fig, master=self.graph_frame
|
|
||||||
)
|
|
||||||
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
|
||||||
|
|
||||||
# Store current data for replotting
|
# Pack canvas
|
||||||
self.current_data: pd.DataFrame = pd.DataFrame()
|
canvas_widget = self.canvas.get_tk_widget()
|
||||||
|
canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
# Create control frame
|
||||||
|
self.control_frame = ttk.Frame(self.parent_frame)
|
||||||
|
self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
|
||||||
|
|
||||||
def _create_chart_toggles(self) -> None:
|
def _create_chart_toggles(self) -> None:
|
||||||
"""Create toggle controls for chart elements."""
|
"""Create toggle controls for chart elements with improved layout."""
|
||||||
ttk.Label(self.control_frame, text="Show/Hide Elements:").pack(
|
# Pathology toggles
|
||||||
side="left", padx=5
|
pathology_frame = ttk.LabelFrame(
|
||||||
|
self.control_frame, text="Pathologies", padding="5"
|
||||||
)
|
)
|
||||||
|
pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
|
||||||
|
|
||||||
# Pathologies toggles - dynamic based on pathology manager
|
# Use grid for better layout
|
||||||
pathologies_frame = ttk.LabelFrame(self.control_frame, text="Pathologies")
|
row, col = 0, 0
|
||||||
pathologies_frame.pack(side="left", padx=5, pady=2)
|
|
||||||
|
|
||||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||||
if pathology:
|
if pathology:
|
||||||
checkbox = ttk.Checkbutton(
|
display_name = pathology.display_name
|
||||||
pathologies_frame,
|
text = (
|
||||||
text=pathology.display_name,
|
display_name[:10] + "..."
|
||||||
|
if len(display_name) > 10
|
||||||
|
else display_name
|
||||||
|
)
|
||||||
|
cb = ttk.Checkbutton(
|
||||||
|
pathology_frame,
|
||||||
|
text=text,
|
||||||
variable=self.toggle_vars[pathology_key],
|
variable=self.toggle_vars[pathology_key],
|
||||||
command=self._handle_toggle_changed,
|
command=self._handle_toggle_changed,
|
||||||
)
|
)
|
||||||
checkbox.pack(side="left", padx=3)
|
cb.grid(row=row, column=col, sticky="w", padx=2)
|
||||||
|
col += 1
|
||||||
|
if col > 1: # 2 columns max
|
||||||
|
col = 0
|
||||||
|
row += 1
|
||||||
|
|
||||||
# Medicines toggles - dynamic based on medicine manager
|
# Medicine toggles
|
||||||
medicines_frame = ttk.LabelFrame(self.control_frame, text="Medicines")
|
medicine_frame = ttk.LabelFrame(
|
||||||
medicines_frame.pack(side="left", padx=5, pady=2)
|
self.control_frame, text="Medicines", padding="5"
|
||||||
|
)
|
||||||
|
medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2)
|
||||||
|
|
||||||
|
# Use grid for medicines too
|
||||||
|
row, col = 0, 0
|
||||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||||
if medicine:
|
if medicine:
|
||||||
checkbox = ttk.Checkbutton(
|
med_name = medicine.display_name
|
||||||
medicines_frame,
|
text = med_name[:10] + "..." if len(med_name) > 10 else med_name
|
||||||
text=medicine.display_name,
|
cb = ttk.Checkbutton(
|
||||||
|
medicine_frame,
|
||||||
|
text=text,
|
||||||
variable=self.toggle_vars[medicine_key],
|
variable=self.toggle_vars[medicine_key],
|
||||||
command=self._handle_toggle_changed,
|
command=self._handle_toggle_changed,
|
||||||
)
|
)
|
||||||
checkbox.pack(side="left", padx=3)
|
cb.grid(row=row, column=col, sticky="w", padx=2)
|
||||||
|
col += 1
|
||||||
|
if col > 2: # 3 columns max for medicines
|
||||||
|
col = 0
|
||||||
|
row += 1
|
||||||
|
|
||||||
def _handle_toggle_changed(self) -> None:
|
def _handle_toggle_changed(self) -> None:
|
||||||
"""Handle toggle changes by replotting the graph."""
|
"""Handle toggle changes by replotting the graph with optimization."""
|
||||||
if not self.current_data.empty:
|
if not self.current_data.empty:
|
||||||
self._plot_graph_data(self.current_data)
|
self._plot_graph_data(self.current_data)
|
||||||
|
|
||||||
def update_graph(self, df: pd.DataFrame) -> None:
|
def update_graph(self, df: pd.DataFrame) -> None:
|
||||||
"""Update the graph with new data."""
|
"""Update the graph with new data using optimization checks."""
|
||||||
self.current_data = df.copy() if not df.empty else pd.DataFrame()
|
# Create hash of data to avoid unnecessary redraws
|
||||||
self._plot_graph_data(df)
|
data_hash = str(hash(str(df.values.tobytes()) if not df.empty else "empty"))
|
||||||
|
|
||||||
|
# Only update if data actually changed
|
||||||
|
if data_hash != self._last_plot_hash or self.current_data.empty:
|
||||||
|
self.current_data = df.copy() if not df.empty else pd.DataFrame()
|
||||||
|
self._last_plot_hash = data_hash
|
||||||
|
self._plot_graph_data(df)
|
||||||
|
|
||||||
def _plot_graph_data(self, df: pd.DataFrame) -> None:
|
def _plot_graph_data(self, df: pd.DataFrame) -> None:
|
||||||
"""Plot the graph data with current toggle settings."""
|
"""Plot the graph data with current toggle settings using optimizations."""
|
||||||
self.ax.clear()
|
# Use batch updates to reduce redraws
|
||||||
if not df.empty:
|
with plt.ioff(): # Turn off interactive mode for batch updates
|
||||||
# Convert dates and sort
|
self.ax.clear()
|
||||||
df = df.copy() # Create a copy to avoid modifying the original
|
|
||||||
df["date"] = pd.to_datetime(df["date"])
|
|
||||||
df = df.sort_values(by="date")
|
|
||||||
df.set_index(keys="date", inplace=True)
|
|
||||||
|
|
||||||
# Track if any series are plotted
|
if not df.empty:
|
||||||
has_plotted_series = False
|
# Optimize data processing
|
||||||
|
df_processed = self._preprocess_data(df)
|
||||||
|
|
||||||
# Plot pathology data series based on toggle states
|
# Track if any series are plotted
|
||||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
has_plotted_series = self._plot_pathology_data(df_processed)
|
||||||
if self.toggle_vars[pathology_key].get():
|
medicine_data = self._plot_medicine_data(df_processed)
|
||||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
|
||||||
if pathology and pathology_key in df.columns:
|
|
||||||
label = f"{pathology.display_name} ({pathology.scale_info})"
|
|
||||||
linestyle = (
|
|
||||||
"dashed"
|
|
||||||
if pathology.scale_orientation == "inverted"
|
|
||||||
else "-"
|
|
||||||
)
|
|
||||||
self._plot_series(df, pathology_key, label, "o", linestyle)
|
|
||||||
has_plotted_series = True
|
|
||||||
|
|
||||||
# Plot medicine dose data
|
if has_plotted_series or medicine_data["has_plotted"]:
|
||||||
# Get medicine colors from medicine manager
|
self._configure_graph_appearance(medicine_data)
|
||||||
medicine_colors = self.medicine_manager.get_graph_colors()
|
|
||||||
|
|
||||||
# Get medicines dynamically from medicine manager
|
# Single draw call at the end
|
||||||
medicines = self.medicine_manager.get_medicine_keys()
|
self.canvas.draw_idle()
|
||||||
|
|
||||||
# Track medicines with and without data for legend
|
def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
medicines_with_data = []
|
"""Preprocess data for plotting with optimizations."""
|
||||||
medicines_without_data = []
|
df = df.copy()
|
||||||
|
# Batch convert dates and sort
|
||||||
|
df["date"] = pd.to_datetime(df["date"], cache=True)
|
||||||
|
df = df.sort_values(by="date")
|
||||||
|
df.set_index(keys="date", inplace=True)
|
||||||
|
return df
|
||||||
|
|
||||||
for medicine in medicines:
|
def _plot_pathology_data(self, df: pd.DataFrame) -> bool:
|
||||||
dose_column = f"{medicine}_doses"
|
"""Plot pathology data series with optimizations."""
|
||||||
if self.toggle_vars[medicine].get() and dose_column in df.columns:
|
has_plotted_series = False
|
||||||
# Calculate daily dose totals
|
|
||||||
daily_doses = []
|
|
||||||
for dose_str in df[dose_column]:
|
|
||||||
total_dose = self._calculate_daily_dose(dose_str)
|
|
||||||
daily_doses.append(total_dose)
|
|
||||||
|
|
||||||
# Only plot if there are non-zero doses
|
# Batch plot pathology data
|
||||||
if any(dose > 0 for dose in daily_doses):
|
pathology_keys = self.pathology_manager.get_pathology_keys()
|
||||||
medicines_with_data.append(medicine)
|
active_pathologies = [
|
||||||
# Scale doses for better visibility
|
key
|
||||||
# (divide by 10 to fit with 0-10 scale)
|
for key in pathology_keys
|
||||||
scaled_doses = [dose / 10 for dose in daily_doses]
|
if self.toggle_vars[key].get() and key in df.columns
|
||||||
|
]
|
||||||
|
|
||||||
# Calculate total dosage for this medicine across all days
|
for pathology_key in active_pathologies:
|
||||||
total_medicine_dose = sum(daily_doses)
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||||
non_zero_doses = [d for d in daily_doses if d > 0]
|
if pathology:
|
||||||
avg_dose = total_medicine_dose / len(non_zero_doses)
|
label = f"{pathology.display_name} ({pathology.scale_info})"
|
||||||
|
linestyle = (
|
||||||
|
"dashed" if pathology.scale_orientation == "inverted" else "-"
|
||||||
|
)
|
||||||
|
self._plot_series(df, pathology_key, label, "o", linestyle)
|
||||||
|
has_plotted_series = True
|
||||||
|
|
||||||
# Create more informative label
|
return has_plotted_series
|
||||||
|
|
||||||
|
def _plot_medicine_data(self, df: pd.DataFrame) -> dict:
|
||||||
|
"""Plot medicine data with optimizations."""
|
||||||
|
result = {"has_plotted": False, "with_data": [], "without_data": []}
|
||||||
|
|
||||||
|
# Get medicine colors and keys in batch
|
||||||
|
medicine_colors = self.medicine_manager.get_graph_colors()
|
||||||
|
medicines = self.medicine_manager.get_medicine_keys()
|
||||||
|
|
||||||
|
# Pre-calculate daily doses for all medicines to avoid repeated computation
|
||||||
|
medicine_doses = {}
|
||||||
|
for medicine in medicines:
|
||||||
|
dose_column = f"{medicine}_doses"
|
||||||
|
if dose_column in df.columns:
|
||||||
|
daily_doses = [
|
||||||
|
self._calculate_daily_dose(dose_str) for dose_str in df[dose_column]
|
||||||
|
]
|
||||||
|
medicine_doses[medicine] = daily_doses
|
||||||
|
|
||||||
|
# Plot medicines with data
|
||||||
|
for medicine in medicines:
|
||||||
|
if self.toggle_vars[medicine].get() and medicine in medicine_doses:
|
||||||
|
daily_doses = medicine_doses[medicine]
|
||||||
|
|
||||||
|
# Check if there's any data to plot
|
||||||
|
if any(dose > 0 for dose in daily_doses):
|
||||||
|
result["with_data"].append(medicine)
|
||||||
|
|
||||||
|
# Optimize dose scaling and bar plotting
|
||||||
|
scaled_doses = [dose / 10 for dose in daily_doses]
|
||||||
|
|
||||||
|
# Calculate statistics more efficiently
|
||||||
|
non_zero_doses = [d for d in daily_doses if d > 0]
|
||||||
|
if non_zero_doses:
|
||||||
|
avg_dose = sum(daily_doses) / len(non_zero_doses)
|
||||||
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
|
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
|
||||||
|
|
||||||
|
# Single bar plot call
|
||||||
self.ax.bar(
|
self.ax.bar(
|
||||||
df.index,
|
df.index,
|
||||||
scaled_doses,
|
scaled_doses,
|
||||||
@@ -193,56 +234,59 @@ class GraphManager:
|
|||||||
width=0.6,
|
width=0.6,
|
||||||
bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1,
|
bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1,
|
||||||
)
|
)
|
||||||
has_plotted_series = True
|
result["has_plotted"] = True
|
||||||
else:
|
else:
|
||||||
# Medicine is toggled on but has no dose data
|
# Medicine is toggled on but has no dose data
|
||||||
if self.toggle_vars[medicine].get():
|
if self.toggle_vars[medicine].get():
|
||||||
medicines_without_data.append(medicine)
|
result["without_data"].append(medicine)
|
||||||
|
|
||||||
# Configure graph appearance
|
return result
|
||||||
if has_plotted_series:
|
|
||||||
# Get current legend handles and labels
|
|
||||||
handles, labels = self.ax.get_legend_handles_labels()
|
|
||||||
|
|
||||||
# Add information about medicines without data if any are toggled on
|
def _configure_graph_appearance(self, medicine_data: dict) -> None:
|
||||||
if medicines_without_data:
|
"""Configure graph appearance with optimizations."""
|
||||||
# Add a text note about medicines without dose data
|
# Get legend data in batch
|
||||||
med_list = ", ".join(medicines_without_data)
|
handles, labels = self.ax.get_legend_handles_labels()
|
||||||
info_text = f"Tracked (no doses): {med_list}"
|
|
||||||
labels.append(info_text)
|
|
||||||
# Create a dummy handle for the info text (invisible)
|
|
||||||
from matplotlib.patches import Rectangle
|
|
||||||
|
|
||||||
dummy_handle = Rectangle(
|
# Add information about medicines without data if any are toggled on
|
||||||
(0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0
|
if medicine_data["without_data"]:
|
||||||
)
|
med_list = ", ".join(medicine_data["without_data"])
|
||||||
handles.append(dummy_handle)
|
info_text = f"Tracked (no doses): {med_list}"
|
||||||
|
labels.append(info_text)
|
||||||
|
|
||||||
# Create an expanded legend with better formatting
|
# Create dummy handle more efficiently
|
||||||
self.ax.legend(
|
from matplotlib.patches import Rectangle
|
||||||
handles,
|
|
||||||
labels,
|
|
||||||
loc="upper left",
|
|
||||||
bbox_to_anchor=(0, 1),
|
|
||||||
ncol=2, # Display in 2 columns for better space usage
|
|
||||||
fontsize="small",
|
|
||||||
frameon=True,
|
|
||||||
fancybox=True,
|
|
||||||
shadow=True,
|
|
||||||
framealpha=0.9,
|
|
||||||
)
|
|
||||||
self.ax.set_title("Medication Effects Over Time")
|
|
||||||
self.ax.set_xlabel("Date")
|
|
||||||
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
|
|
||||||
|
|
||||||
# Adjust y-axis to accommodate medicine bars at bottom
|
dummy_handle = Rectangle(
|
||||||
current_ylim = self.ax.get_ylim()
|
(0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0
|
||||||
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1]))
|
)
|
||||||
|
handles.append(dummy_handle)
|
||||||
|
|
||||||
self.fig.autofmt_xdate()
|
# Create legend with optimized settings
|
||||||
|
if handles and labels:
|
||||||
|
self.ax.legend(
|
||||||
|
handles,
|
||||||
|
labels,
|
||||||
|
loc="upper left",
|
||||||
|
bbox_to_anchor=(0, 1),
|
||||||
|
ncol=2,
|
||||||
|
fontsize="small",
|
||||||
|
frameon=True,
|
||||||
|
fancybox=True,
|
||||||
|
shadow=True,
|
||||||
|
framealpha=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
# Redraw the canvas
|
# Set titles and labels
|
||||||
self.canvas.draw()
|
self.ax.set_title("Medication Effects Over Time")
|
||||||
|
self.ax.set_xlabel("Date")
|
||||||
|
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
|
||||||
|
|
||||||
|
# Optimize y-axis configuration
|
||||||
|
current_ylim = self.ax.get_ylim()
|
||||||
|
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1]))
|
||||||
|
|
||||||
|
# Optimize date formatting
|
||||||
|
self.fig.autofmt_xdate()
|
||||||
|
|
||||||
def _plot_series(
|
def _plot_series(
|
||||||
self,
|
self,
|
||||||
@@ -252,25 +296,28 @@ class GraphManager:
|
|||||||
marker: str,
|
marker: str,
|
||||||
linestyle: str,
|
linestyle: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper method to plot a data series."""
|
"""Helper method to plot a data series with optimizations."""
|
||||||
|
# Use more efficient plotting parameters
|
||||||
self.ax.plot(
|
self.ax.plot(
|
||||||
df.index,
|
df.index,
|
||||||
df[column],
|
df[column],
|
||||||
marker=marker,
|
marker=marker,
|
||||||
linestyle=linestyle,
|
linestyle=linestyle,
|
||||||
label=label,
|
label=label,
|
||||||
|
markersize=4, # Smaller markers for better performance
|
||||||
|
linewidth=1.5, # Optimized line width
|
||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_daily_dose(self, dose_str: str) -> float:
|
def _calculate_daily_dose(self, dose_str: str) -> float:
|
||||||
"""Calculate total daily dose from dose string format."""
|
"""Calculate total daily dose from dose string format with optimizations."""
|
||||||
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
|
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
total_dose = 0.0
|
total_dose = 0.0
|
||||||
# Handle different separators and clean the string
|
# Optimize string processing
|
||||||
dose_str = str(dose_str).replace("•", "").strip()
|
dose_str = str(dose_str).replace("•", "").strip()
|
||||||
|
|
||||||
# Split by | or by spaces if no | present
|
# More efficient splitting and processing
|
||||||
dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str]
|
dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str]
|
||||||
|
|
||||||
for entry in dose_entries:
|
for entry in dose_entries:
|
||||||
@@ -279,15 +326,15 @@ class GraphManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract dose part after the last colon (timestamp:dose format)
|
# More efficient dose extraction
|
||||||
dose_part = entry.split(":")[-1] if ":" in entry else entry
|
dose_part = entry.split(":")[-1] if ":" in entry else entry
|
||||||
|
|
||||||
# Extract numeric part from dose (e.g., "150mg" -> 150)
|
# Optimized numeric extraction
|
||||||
dose_value = ""
|
dose_value = ""
|
||||||
for char in dose_part:
|
for char in dose_part:
|
||||||
if char.isdigit() or char == ".":
|
if char.isdigit() or char == ".":
|
||||||
dose_value += char
|
dose_value += char
|
||||||
elif dose_value: # Stop at first non-digit after finding digits
|
elif dose_value:
|
||||||
break
|
break
|
||||||
|
|
||||||
if dose_value:
|
if dose_value:
|
||||||
@@ -298,5 +345,10 @@ class GraphManager:
|
|||||||
return total_dose
|
return total_dose
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Clean up resources."""
|
"""Clean up resources with proper optimization."""
|
||||||
plt.close(self.fig)
|
try:
|
||||||
|
# Clear the plot before closing
|
||||||
|
self.ax.clear()
|
||||||
|
plt.close(self.fig)
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore cleanup errors
|
||||||
|
|||||||
+39
-4
@@ -67,6 +67,29 @@ class MedTrackerApp:
|
|||||||
# Add menu bar
|
# Add menu bar
|
||||||
self._setup_menu()
|
self._setup_menu()
|
||||||
|
|
||||||
|
# Center the window on screen
|
||||||
|
self._center_window()
|
||||||
|
|
||||||
|
def _center_window(self) -> None:
|
||||||
|
"""Center the main window on the screen."""
|
||||||
|
# Update the window to get accurate dimensions
|
||||||
|
self.root.update_idletasks()
|
||||||
|
|
||||||
|
# Get window dimensions
|
||||||
|
window_width = self.root.winfo_reqwidth()
|
||||||
|
window_height = self.root.winfo_reqheight()
|
||||||
|
|
||||||
|
# Get screen dimensions
|
||||||
|
screen_width = self.root.winfo_screenwidth()
|
||||||
|
screen_height = self.root.winfo_screenheight()
|
||||||
|
|
||||||
|
# Calculate position to center the window
|
||||||
|
x = (screen_width // 2) - (window_width // 2)
|
||||||
|
y = (screen_height // 2) - (window_height // 2)
|
||||||
|
|
||||||
|
# Set the window geometry
|
||||||
|
self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||||
|
|
||||||
def _setup_main_ui(self) -> None:
|
def _setup_main_ui(self) -> None:
|
||||||
"""Set up the main UI components."""
|
"""Set up the main UI components."""
|
||||||
import tkinter.ttk as ttk
|
import tkinter.ttk as ttk
|
||||||
@@ -150,6 +173,12 @@ class MedTrackerApp:
|
|||||||
|
|
||||||
def _refresh_ui_after_config_change(self) -> None:
|
def _refresh_ui_after_config_change(self) -> None:
|
||||||
"""Refresh UI components after pathology or medicine configuration changes."""
|
"""Refresh UI components after pathology or medicine configuration changes."""
|
||||||
|
# Clear caches in optimized data manager
|
||||||
|
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||||
|
self.data_manager._invalidate_cache()
|
||||||
|
self.data_manager._headers_cache = None
|
||||||
|
self.data_manager._dtype_cache = None
|
||||||
|
|
||||||
# Recreate the input frame with new pathologies and medicines
|
# Recreate the input frame with new pathologies and medicines
|
||||||
self.input_frame.destroy()
|
self.input_frame.destroy()
|
||||||
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(
|
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(
|
||||||
@@ -412,9 +441,10 @@ class MedTrackerApp:
|
|||||||
"""Load data from the CSV file into the table and graph."""
|
"""Load data from the CSV file into the table and graph."""
|
||||||
logger.debug("Loading data from CSV.")
|
logger.debug("Loading data from CSV.")
|
||||||
|
|
||||||
# Clear existing data in the treeview
|
# Clear existing data in the treeview efficiently
|
||||||
for i in self.tree.get_children():
|
children = self.tree.get_children()
|
||||||
self.tree.delete(i)
|
if children:
|
||||||
|
self.tree.delete(*children)
|
||||||
|
|
||||||
# Load data from the CSV file
|
# Load data from the CSV file
|
||||||
df: pd.DataFrame = self.data_manager.load_data()
|
df: pd.DataFrame = self.data_manager.load_data()
|
||||||
@@ -422,7 +452,11 @@ class MedTrackerApp:
|
|||||||
# Update the treeview with the data
|
# Update the treeview with the data
|
||||||
if not df.empty:
|
if not df.empty:
|
||||||
# Build display columns dynamically (exclude dose columns for table view)
|
# Build display columns dynamically (exclude dose columns for table view)
|
||||||
display_columns = ["date", "depression", "anxiety", "sleep", "appetite"]
|
display_columns = ["date"]
|
||||||
|
|
||||||
|
# Add pathology columns
|
||||||
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
|
display_columns.append(pathology_key)
|
||||||
|
|
||||||
# Add medicine columns (without dose columns)
|
# Add medicine columns (without dose columns)
|
||||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
@@ -437,6 +471,7 @@ class MedTrackerApp:
|
|||||||
# Fallback - just use all columns
|
# Fallback - just use all columns
|
||||||
display_df = df
|
display_df = df
|
||||||
|
|
||||||
|
# Batch insert for better performance
|
||||||
for _index, row in display_df.iterrows():
|
for _index, row in display_df.iterrows():
|
||||||
self.tree.insert(parent="", index="end", values=list(row))
|
self.tree.insert(parent="", index="end", values=list(row))
|
||||||
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
|
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
|
||||||
|
|||||||
Reference in New Issue
Block a user