Spaces:
Build error
Build error
| # hardy_cross_plot.py | |
| """ | |
| Headless plotting utility for the Hardy Cross app. | |
| - Uses the non-GUI Agg backend (safe on HF Spaces / servers). | |
| - Accepts a DataFrame of final flows (must include a 'pipe_id' column and a 'Q' column). | |
| - If available, will use 'start_node' and 'end_node' columns to determine pipe direction. | |
| - Otherwise, infers endpoints from the pipe_id (e.g., 'AB' -> 'A' -> 'B'). | |
| - Auto-lays out nodes on a circle unless explicit positions are provided. | |
| Example: | |
| import pandas as pd | |
| from hardy_cross_plot import plot_hardy_cross_network | |
| df = pd.DataFrame({ | |
| "pipe_id": ["AB", "BC", "CD", "DA", "AC"], | |
| "Q": [0.08, 0.05, -0.06, -0.07, 0.02] | |
| }) | |
| path = plot_hardy_cross_network(df, save_path="hardy_cross_network_result.png") | |
| """ | |
| import os | |
| import math | |
| import matplotlib | |
| matplotlib.use("Agg") # critical: headless backend for servers/Spaces | |
| import matplotlib.pyplot as plt | |
| from typing import Dict, Tuple, Optional | |
| import pandas as pd | |
| import numpy as np | |
| def _infer_endpoints_from_pipe_id(pipe_id: str) -> Tuple[str, str]: | |
| """ | |
| Try to infer start/end node names from a pipe_id. | |
| Strategy: | |
| - If it looks like 'AB' or 'A-B', use first and last alphanumeric chunks. | |
| - Otherwise, fall back to first and last characters. | |
| """ | |
| if not isinstance(pipe_id, str) or len(pipe_id.strip()) == 0: | |
| return ("?", "?") | |
| s = pipe_id.strip() | |
| # Try split on common separators first | |
| for sep in ("-", "—", ">", "→"): | |
| if sep in s: | |
| parts = [p for p in s.split(sep) if p] | |
| if len(parts) >= 2: | |
| return (parts[0].strip(), parts[-1].strip()) | |
| # Extract alphanumeric chunks; pick first and last chunk | |
| chunks = [] | |
| cur = [] | |
| for ch in s: | |
| if ch.isalnum(): | |
| cur.append(ch) | |
| elif cur: | |
| chunks.append("".join(cur)) | |
| cur = [] | |
| if cur: | |
| chunks.append("".join(cur)) | |
| if len(chunks) >= 2: | |
| return (chunks[0], chunks[-1]) | |
| # Fallback: first and last character | |
| return (s[0], s[-1]) | |
| def _collect_nodes(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Ensure the dataframe has 'start_node' and 'end_node' columns. | |
| If absent, infer endpoints from 'pipe_id'. | |
| Returns a shallow copy with added columns if needed. | |
| """ | |
| df2 = df.copy() | |
| has_start = "start_node" in df2.columns | |
| has_end = "end_node" in df2.columns | |
| if not has_start or not has_end: | |
| starts = [] | |
| ends = [] | |
| for pid in df2["pipe_id"].astype(str): | |
| s, e = _infer_endpoints_from_pipe_id(pid) | |
| starts.append(s) | |
| ends.append(e) | |
| df2["start_node"] = starts | |
| df2["end_node"] = ends | |
| return df2 | |
| def _default_positions(nodes: list) -> Dict[str, Tuple[float, float]]: | |
| """ | |
| Place nodes on a circle for a clean, general layout. | |
| """ | |
| n = max(len(nodes), 1) | |
| R = 1.0 # radius | |
| positions = {} | |
| for i, node in enumerate(nodes): | |
| theta = 2 * math.pi * i / n | |
| x = R * math.cos(theta) | |
| y = R * math.sin(theta) | |
| positions[node] = (x, y) | |
| return positions | |
| def plot_hardy_cross_network( | |
| final_flows: pd.DataFrame, | |
| save_path: str = "hardy_cross_network_result.png", | |
| node_positions: Optional[Dict[str, Tuple[float, float]]] = None, | |
| figsize: Tuple[int, int] = (8, 5), | |
| ) -> str: | |
| """ | |
| Create a simple network plot with arrows indicating flow direction and magnitude. | |
| Parameters | |
| ---------- | |
| final_flows : pd.DataFrame | |
| Must contain at least 'pipe_id' and 'Q'. | |
| If 'start_node' and 'end_node' exist, they are used; otherwise inferred from 'pipe_id'. | |
| save_path : str | |
| Where to save the PNG. | |
| node_positions : dict, optional | |
| Mapping {node: (x, y)}. If None, nodes are placed on a circle. | |
| figsize : tuple | |
| Matplotlib figure size in inches. | |
| Returns | |
| ------- | |
| str | |
| The path to the saved PNG (only if saved successfully). | |
| """ | |
| if final_flows is None or len(final_flows) == 0: | |
| # Create an empty placeholder figure to avoid crashing the UI | |
| fig, ax = plt.subplots(figsize=figsize) | |
| ax.text(0.5, 0.5, "No data to plot", ha="center", va="center") | |
| ax.axis("off") | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150) | |
| plt.close(fig) | |
| return save_path | |
| if "pipe_id" not in final_flows.columns or "Q" not in final_flows.columns: | |
| # Same: graceful placeholder | |
| fig, ax = plt.subplots(figsize=figsize) | |
| ax.text(0.5, 0.5, "Missing columns: require 'pipe_id' and 'Q'", ha="center", va="center") | |
| ax.axis("off") | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150) | |
| plt.close(fig) | |
| return save_path | |
| df = _collect_nodes(final_flows) | |
| # Collect unique nodes and positions | |
| nodes = sorted(set(df["start_node"].astype(str)) | set(df["end_node"].astype(str))) | |
| if not node_positions: | |
| node_positions = _default_positions(nodes) | |
| # Scale arrows by relative magnitude for readability | |
| # Normalize by max |Q| | |
| q_abs = final_flows["Q"].abs().replace(0.0, np.nan) | |
| q_max = np.nanmax(q_abs.values) if len(q_abs) else 1.0 | |
| if not np.isfinite(q_max) or q_max <= 0: | |
| q_max = 1.0 | |
| fig, ax = plt.subplots(figsize=figsize) | |
| ax.set_aspect("equal") | |
| ax.axis("off") | |
| ax.set_title("Hardy Cross Network Flows", fontsize=14, pad=12) | |
| # Draw nodes | |
| for node in nodes: | |
| x, y = node_positions.get(node, (0.0, 0.0)) | |
| ax.plot(x, y, "ko", ms=5) | |
| ax.text(x, y + 0.06, str(node), ha="center", va="bottom", fontsize=11, fontweight="bold") | |
| # Draw pipes with arrows and labels | |
| for _, row in df.iterrows(): | |
| pid = str(row["pipe_id"]) | |
| s = str(row["start_node"]) | |
| e = str(row["end_node"]) | |
| q = float(row["Q"]) | |
| x1, y1 = node_positions.get(s, (0.0, 0.0)) | |
| x2, y2 = node_positions.get(e, (0.0, 0.0)) | |
| # Direction: if Q >= 0, arrow from start->end; else reverse | |
| if q >= 0: | |
| sx, sy, tx, ty = x1, y1, x2, y2 | |
| else: | |
| sx, sy, tx, ty = x2, y2, x1, y1 | |
| # Arrow style scaled by |Q| | |
| width = 1.0 + 3.0 * (abs(q) / q_max) # line width in points | |
| head_width = 0.02 + 0.06 * (abs(q) / q_max) | |
| head_length = 0.03 + 0.09 * (abs(q) / q_max) | |
| ax.annotate( | |
| "", | |
| xy=(tx, ty), | |
| xytext=(sx, sy), | |
| arrowprops=dict( | |
| arrowstyle="-|>", | |
| lw=width, | |
| shrinkA=5, | |
| shrinkB=5, | |
| mutation_scale=12 + 30 * (abs(q) / q_max), | |
| color="tab:blue", | |
| ), | |
| ) | |
| # Label with flow value near the pipe midpoint | |
| mx = 0.5 * (x1 + x2) | |
| my = 0.5 * (y1 + y2) | |
| ax.text( | |
| mx, my, | |
| f"{q:.3f} m³/s", | |
| ha="center", va="center", | |
| fontsize=10, color="tab:blue" | |
| ) | |
| fig.tight_layout() | |
| # Ensure directory exists and save | |
| out_dir = os.path.dirname(save_path) | |
| if out_dir and not os.path.isdir(out_dir): | |
| os.makedirs(out_dir, exist_ok=True) | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight") | |
| plt.close(fig) | |
| return save_path | |