xslope 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xslope/mesh.py CHANGED
@@ -1305,6 +1305,12 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1305
1305
  proj_left_x = None
1306
1306
  proj_right_x = None
1307
1307
  bottom_cleaned = []
1308
+
1309
+ # Initialize vertical edge points (used for intermediate points on vertical edges)
1310
+ left_vertical_points = [] # Intermediate points on left vertical edge (bottom to top)
1311
+ right_vertical_points = [] # Intermediate points on right vertical edge (top to bottom)
1312
+ left_y_bot = -np.inf
1313
+ right_y_bot = -np.inf
1308
1314
 
1309
1315
  if i < n - 1:
1310
1316
  # Use the immediate next line as the lower boundary
@@ -1356,21 +1362,112 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1356
1362
  # Convert to sorted list
1357
1363
  bottom_cleaned = sorted([(orig_x, orig_y) for _, _, orig_x, orig_y in bottom_dict.values()])
1358
1364
 
1365
+ # Helper function to check if a point already exists in a list
1366
+ def point_exists(point_list, x, y, tol=1e-8):
1367
+ """Check if a point (x, y) already exists in the point list within tolerance."""
1368
+ for px, py in point_list:
1369
+ if abs(px - x) < tol and abs(py - y) < tol:
1370
+ return True
1371
+ return False
1372
+
1373
+ # Helper function to find the lowest y value at a given x by checking all segments
1374
+ def find_lowest_y_at_x(line_points, x_query, tol=1e-8):
1375
+ """
1376
+ Find the lowest y value at x_query by checking all segments of the line.
1377
+ Handles vertical segments properly by finding all y values at that x and returning the minimum.
1378
+
1379
+ Returns:
1380
+ tuple: (y_value, is_at_endpoint) where is_at_endpoint indicates if x_query is at an endpoint
1381
+ """
1382
+ if not line_points:
1383
+ return None, False
1384
+
1385
+ xs = np.array([x for x, y in line_points])
1386
+ ys = np.array([y for x, y in line_points])
1387
+
1388
+ # Check if x_query is within the line's x-range
1389
+ if xs[0] - tol > x_query or xs[-1] + tol < x_query:
1390
+ return None, False
1391
+
1392
+ # Check if x_query is at an endpoint
1393
+ is_at_left_endpoint = abs(x_query - xs[0]) < tol
1394
+ is_at_right_endpoint = abs(x_query - xs[-1]) < tol
1395
+ is_at_endpoint = is_at_left_endpoint or is_at_right_endpoint
1396
+
1397
+ # Find all y values at x_query by checking all segments
1398
+ y_values = []
1399
+
1400
+ # Check all points that are exactly at x_query
1401
+ for k in range(len(line_points)):
1402
+ if abs(xs[k] - x_query) < tol:
1403
+ y_values.append(ys[k])
1404
+
1405
+ # Check all segments that contain x_query
1406
+ for k in range(len(line_points) - 1):
1407
+ x1, y1 = line_points[k]
1408
+ x2, y2 = line_points[k + 1]
1409
+
1410
+ # Check if segment is vertical and contains x_query
1411
+ if abs(x1 - x_query) < tol and abs(x2 - x_query) < tol:
1412
+ # Vertical segment - include both y values
1413
+ y_values.append(y1)
1414
+ y_values.append(y2)
1415
+ # Check if segment is horizontal or sloped and contains x_query
1416
+ elif min(x1, x2) - tol <= x_query <= max(x1, x2) + tol:
1417
+ # Interpolate y value
1418
+ if abs(x2 - x1) < tol:
1419
+ # Segment is vertical (should have been caught above, but just in case)
1420
+ y_values.append(y1)
1421
+ y_values.append(y2)
1422
+ else:
1423
+ # Linear interpolation
1424
+ t = (x_query - x1) / (x2 - x1)
1425
+ if 0 <= t <= 1:
1426
+ y_interp = y1 + t * (y2 - y1)
1427
+ y_values.append(y_interp)
1428
+
1429
+ if not y_values:
1430
+ return None, False
1431
+
1432
+ # Return the lowest y value
1433
+ y_min = min(y_values)
1434
+ return y_min, is_at_endpoint
1435
+
1359
1436
  # Project endpoints - find highest lower profile or use max_depth
1360
- left_y_bot = -np.inf
1361
- right_y_bot = -np.inf
1437
+ # When projecting right side: if intersection is at left end of lower line,
1438
+ # add that point but continue projecting down
1439
+ # When projecting left side: if intersection is at right end of lower line,
1440
+ # add that point but continue projecting down
1362
1441
  for j in range(i + 1, n):
1363
1442
  lower_candidate = lines[j]
1364
1443
  xs_cand = np.array([x for x, y in lower_candidate])
1365
1444
  ys_cand = np.array([y for x, y in lower_candidate])
1445
+
1446
+ # Check left endpoint projection
1366
1447
  if xs_cand[0] - tol <= left_x <= xs_cand[-1] + tol:
1367
- y_cand = np.interp(left_x, xs_cand, ys_cand)
1368
- if y_cand > left_y_bot:
1369
- left_y_bot = y_cand
1448
+ y_cand, is_at_endpoint = find_lowest_y_at_x(lower_candidate, left_x, tol)
1449
+ if y_cand is not None:
1450
+ # If intersection is at the right end of the lower line, add point but continue
1451
+ if is_at_endpoint and abs(left_x - xs_cand[-1]) < tol: # At right endpoint
1452
+ # Only add if not duplicate of the endpoint being projected and not already in list
1453
+ if abs(y_cand - left_y) > tol and not point_exists(left_vertical_points, left_x, y_cand, tol):
1454
+ left_vertical_points.append((left_x, y_cand))
1455
+ else: # Not at endpoint, use as stopping point
1456
+ if y_cand > left_y_bot:
1457
+ left_y_bot = y_cand
1458
+
1459
+ # Check right endpoint projection
1370
1460
  if xs_cand[0] - tol <= right_x <= xs_cand[-1] + tol:
1371
- y_cand = np.interp(right_x, xs_cand, ys_cand)
1372
- if y_cand > right_y_bot:
1373
- right_y_bot = y_cand
1461
+ y_cand, is_at_endpoint = find_lowest_y_at_x(lower_candidate, right_x, tol)
1462
+ if y_cand is not None:
1463
+ # If intersection is at the left end of the lower line, add point but continue
1464
+ if is_at_endpoint and abs(right_x - xs_cand[0]) < tol: # At left endpoint
1465
+ # Only add if not duplicate of the endpoint being projected and not already in list
1466
+ if abs(y_cand - right_y) > tol and not point_exists(right_vertical_points, right_x, y_cand, tol):
1467
+ right_vertical_points.append((right_x, y_cand))
1468
+ else: # Not at endpoint, use as stopping point
1469
+ if y_cand > right_y_bot:
1470
+ right_y_bot = y_cand
1374
1471
 
1375
1472
  # If no lower profile at endpoints, use max_depth
1376
1473
  if left_y_bot == -np.inf:
@@ -1378,6 +1475,30 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1378
1475
  if right_y_bot == -np.inf:
1379
1476
  right_y_bot = max_depth if max_depth is not None else -np.inf
1380
1477
 
1478
+ # Deduplicate vertical points (remove points that are too close to each other)
1479
+ def deduplicate_points(points, tol=1e-8):
1480
+ """Remove duplicate points within tolerance."""
1481
+ if not points:
1482
+ return []
1483
+ unique_points = [points[0]]
1484
+ for p in points[1:]:
1485
+ # Check if this point is too close to any existing unique point
1486
+ is_duplicate = False
1487
+ for up in unique_points:
1488
+ if abs(p[0] - up[0]) < tol and abs(p[1] - up[1]) < tol:
1489
+ is_duplicate = True
1490
+ break
1491
+ if not is_duplicate:
1492
+ unique_points.append(p)
1493
+ return unique_points
1494
+
1495
+ right_vertical_points = deduplicate_points(right_vertical_points, tol)
1496
+ left_vertical_points = deduplicate_points(left_vertical_points, tol)
1497
+
1498
+ # Sort vertical points: right edge top to bottom, left edge bottom to top
1499
+ right_vertical_points.sort(key=lambda p: -p[1]) # Sort by y descending (top to bottom)
1500
+ left_vertical_points.sort(key=lambda p: p[1]) # Sort by y ascending (bottom to top)
1501
+
1381
1502
  # Build bottom boundary: right projection, intermediate points (right to left), left projection
1382
1503
  # The bottom should go from right to left to close the polygon
1383
1504
  bottom = []
@@ -1402,16 +1523,42 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1402
1523
  else:
1403
1524
  # For the lowest polygon, bottom is at max_depth
1404
1525
  # Only need endpoints - no intermediate points
1526
+ left_y_bot = max_depth if max_depth is not None else -np.inf
1527
+ right_y_bot = max_depth if max_depth is not None else -np.inf
1405
1528
  bottom = []
1406
1529
  bottom.append((right_x, max_depth))
1407
1530
  bottom.append((left_x, max_depth))
1408
1531
 
1409
- # Build polygon: top left-to-right, bottom right-to-left
1532
+ # Build polygon: top left-to-right, right vertical edge (with intermediate points),
1533
+ # bottom right-to-left, left vertical edge (with intermediate points)
1410
1534
  poly = []
1535
+
1536
+ # Top edge: left to right along profile line
1411
1537
  for x, y in zip(xs_top, ys_top):
1412
1538
  poly.append((round(x, 6), round(y, 6)))
1539
+
1540
+ # Right vertical edge: from (right_x, right_y) down to (right_x, right_y_bot)
1541
+ # Include intermediate points where we intersect left endpoints of lower lines
1542
+ # Note: (right_x, right_y_bot) will be added as part of the bottom edge, so don't add it here
1543
+ if i < n - 1:
1544
+ for x, y in right_vertical_points:
1545
+ # Only add if it's between top and bottom (not duplicate of endpoints)
1546
+ if abs(y - right_y) > tol and abs(y - right_y_bot) > tol:
1547
+ poly.append((round(x, 6), round(y, 6)))
1548
+
1549
+ # Bottom edge: right to left (already includes (right_x, right_y_bot) and (left_x, left_y_bot))
1413
1550
  for x, y in bottom:
1414
1551
  poly.append((round(x, 6), round(y, 6)))
1552
+
1553
+ # Left vertical edge: from (left_x, left_y_bot) up to (left_x, left_y)
1554
+ # Include intermediate points where we intersect right endpoints of lower lines
1555
+ # Note: (left_x, left_y_bot) was already added as part of the bottom edge
1556
+ if i < n - 1:
1557
+ for x, y in reversed(left_vertical_points): # Reverse to go bottom to top
1558
+ # Only add if it's between bottom and top (not duplicate of endpoints)
1559
+ if abs(y - left_y_bot) > tol and abs(y - left_y) > tol:
1560
+ poly.append((round(x, 6), round(y, 6)))
1561
+
1415
1562
  # Clean up polygon (should rarely do anything)
1416
1563
  poly = clean_polygon(poly)
1417
1564
  polygons.append(poly)
xslope/plot.py CHANGED
@@ -86,8 +86,10 @@ def plot_max_depth(ax, profile_lines, max_depth):
86
86
  x_max = max(x_vals)
87
87
  ax.hlines(max_depth, x_min, x_max, colors='black', linewidth=1.5, label='Max Depth')
88
88
 
89
- spacing = 5
90
- length = 4
89
+ x_diff = x_max - x_min
90
+ spacing = x_diff / 100
91
+ length = x_diff / 80
92
+
91
93
  angle_rad = np.radians(60)
92
94
  dx = length * np.cos(angle_rad)
93
95
  dy = length * np.sin(angle_rad)
xslope/plot_seep.py CHANGED
@@ -217,20 +217,73 @@ def plot_seep_data(seep_data, figsize=(14, 6), show_nodes=False, show_bc=False,
217
217
  plt.show()
218
218
 
219
219
 
220
- def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat=1, fill_contours=True, phreatic=True, alpha=0.4, pad_frac=0.05, show_mesh=True):
220
+ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat=1, fill_contours=True, phreatic=True, alpha=0.4, pad_frac=0.05, mesh=True, variable="head", vectors=False, vector_scale=0.05, flowlines=True):
221
221
  """
222
- Plots head contours and optionally overlays flowlines (phi) based on flow function.
223
- Fixed version that properly handles mesh aspect ratio and doesn't clip the plot.
224
- Supports both triangular and quadrilateral elements.
225
-
226
- Arguments:
227
- seep_data: Dictionary containing seepage data from import_seep2d
228
- solution: Dictionary containing solution results from run_analysis
229
- levels: number of head contour levels
230
- base_mat: material ID (1-based) used to compute k for flow function
231
- fill_contours: bool, if True shows filled contours, if False only black solid lines
232
- phreatic: bool, if True plots phreatic surface (pressure head = 0) as thick red line
233
- show_mesh: bool, if True overlays element edges in light gray
222
+ Plot seepage analysis results including head contours, flowlines, and phreatic surface.
223
+
224
+ This function visualizes the results of a seepage analysis by plotting contours of various
225
+ nodal variables (head, pore pressure, velocity magnitude, or gradient magnitude). When
226
+ plotting head, flowlines are also overlaid. The plot properly handles mesh aspect ratios
227
+ and supports both linear and quadratic triangular and quadrilateral elements.
228
+
229
+ Parameters:
230
+ -----------
231
+ seep_data : dict
232
+ Dictionary containing seepage mesh data from import_seep2d. Required keys include:
233
+ 'nodes', 'elements', 'element_materials', 'element_types' (optional), and
234
+ 'k1_by_mat' (optional, for flowline calculation).
235
+ solution : dict
236
+ Dictionary containing solution results from run_seepage_analysis. Required keys include:
237
+ 'head' (array of total head values at nodes), 'velocity' (array of velocity vectors),
238
+ 'gradient' (array of hydraulic gradient vectors). Optional keys: 'phi' (stream function),
239
+ 'flowrate' (total flow rate), 'u' (pore pressure), 'v_mag' (velocity magnitude),
240
+ 'i_mag' (gradient magnitude).
241
+ figsize : tuple of float, optional
242
+ Figure size in inches (width, height). Default is (14, 6).
243
+ levels : int, optional
244
+ Number of contour levels to plot. Default is 20.
245
+ base_mat : int, optional
246
+ Material ID (1-based) used to compute hydraulic conductivity for flow function
247
+ calculation. Default is 1. Only used when variable="head".
248
+ fill_contours : bool, optional
249
+ If True, shows filled contours with color map. If False, only black solid
250
+ contour lines are shown. Default is True.
251
+ phreatic : bool, optional
252
+ If True, plots the phreatic surface (where pressure head = 0) as a thick red line.
253
+ Default is True. Only applicable when variable="head".
254
+ alpha : float, optional
255
+ Transparency level (0-1) for material zone fill colors. Default is 0.4.
256
+ pad_frac : float, optional
257
+ Fraction of mesh extent to add as padding around the plot boundaries. Default is 0.05.
258
+ mesh : bool, optional
259
+ If True, overlays element edges in light gray. Default is True.
260
+ variable : str, optional
261
+ Nodal variable to contour. Options: "head" (default), "u" (pore pressure),
262
+ "v_mag" (velocity magnitude), "i_mag" (gradient magnitude). When "head" is selected,
263
+ flowlines can be overlaid if flowlines=True. Other variables do not include flowlines.
264
+ vectors : bool, optional
265
+ If True, plots velocity vectors as arrows at each node. Default is False.
266
+ vector_scale : float, optional
267
+ Scale factor for vector lengths. Maximum vector length will be x_range * vector_scale,
268
+ where x_range is the x-extent of the mesh. Default is 0.05.
269
+ flowlines : bool, optional
270
+ If True and variable="head", overlays flowlines (stream function contours) on the plot.
271
+ Default is True. Only applicable when variable="head".
272
+
273
+ Returns:
274
+ --------
275
+ None
276
+ Displays the plot using matplotlib.pyplot.show().
277
+
278
+ Notes:
279
+ ------
280
+ - The function automatically subdivides quadratic elements (tri6, quad8, quad9) for
281
+ proper visualization and contouring.
282
+ - Flowlines are only plotted when variable="head" and if 'phi' and 'flowrate' are present
283
+ in solution and 'k1_by_mat' is present in seep_data.
284
+ - The plot includes a colorbar for contours when fill_contours=True.
285
+ - The title includes flowrate information if available in the solution dictionary and
286
+ variable="head".
234
287
  """
235
288
  import matplotlib.pyplot as plt
236
289
  import matplotlib.tri as tri
@@ -238,15 +291,30 @@ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat
238
291
  from matplotlib.patches import Polygon
239
292
  import numpy as np
240
293
 
294
+ # Validate variable parameter
295
+ valid_variables = ["head", "u", "v_mag", "i_mag"]
296
+ if variable not in valid_variables:
297
+ raise ValueError(f"variable must be one of {valid_variables}, got '{variable}'")
298
+
241
299
  # Extract data from seep_data and solution
242
300
  nodes = seep_data["nodes"]
243
301
  elements = seep_data["elements"]
244
302
  element_materials = seep_data["element_materials"]
245
303
  element_types = seep_data.get("element_types", None) # New field for element types
246
304
  k1_by_mat = seep_data.get("k1_by_mat") # Use .get() in case it's not present
305
+
306
+ # Extract the variable to plot
307
+ if variable not in solution:
308
+ raise ValueError(f"Variable '{variable}' not found in solution dictionary. Available keys: {list(solution.keys())}")
309
+ contour_data = solution[variable]
310
+
311
+ # Extract head and flowline-related data (only needed for head plots)
247
312
  head = solution["head"]
248
313
  phi = solution.get("phi")
249
314
  flowrate = solution.get("flowrate")
315
+
316
+ # Determine if we should plot flowlines (only for head and if flowlines=True)
317
+ plot_flowlines = (variable == "head" and flowlines)
250
318
 
251
319
 
252
320
  # Use constrained_layout for best layout
@@ -347,9 +415,9 @@ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat
347
415
  for sub_quad in sub_quads:
348
416
  ax.fill(*zip(*sub_quad), edgecolor='none', facecolor=color, alpha=alpha)
349
417
 
350
- vmin = np.min(head)
351
- vmax = np.max(head)
352
- hdrop = vmax - vmin
418
+ # Set up contour levels
419
+ vmin = np.min(contour_data)
420
+ vmax = np.max(contour_data)
353
421
  contour_levels = np.linspace(vmin, vmax, levels)
354
422
 
355
423
  # For contouring, subdivide tri6 elements into 4 subtriangles
@@ -374,24 +442,41 @@ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat
374
442
  all_triangles_for_contouring.extend([tri1, tri2])
375
443
  triang = tri.Triangulation(nodes[:, 0], nodes[:, 1], all_triangles_for_contouring)
376
444
 
445
+ # Variable labels for colorbar and title
446
+ variable_labels = {
447
+ "head": "Total Head",
448
+ "u": "Pore Pressure",
449
+ "v_mag": "Velocity Magnitude",
450
+ "i_mag": "Hydraulic Gradient Magnitude"
451
+ }
452
+ variable_label = variable_labels[variable]
453
+
377
454
  # Filled contours (only if fill_contours=True)
378
455
  if fill_contours:
379
- contourf = ax.tricontourf(triang, head, levels=contour_levels, cmap="Spectral_r", vmin=vmin, vmax=vmax, alpha=0.5)
380
- cbar = plt.colorbar(contourf, ax=ax, label="Total Head", shrink=0.8, pad=0.02)
456
+ contourf = ax.tricontourf(triang, contour_data, levels=contour_levels, cmap="Spectral_r", vmin=vmin, vmax=vmax, alpha=0.5)
457
+ cbar = plt.colorbar(contourf, ax=ax, label=variable_label, shrink=0.8, pad=0.02)
381
458
  cbar.locator = MaxNLocator(nbins=10, steps=[1, 2, 5])
382
459
  cbar.update_ticks()
383
460
 
384
- # Solid lines for head contours
385
- ax.tricontour(triang, head, levels=contour_levels, colors="k", linewidths=0.5)
386
-
387
- # Phreatic surface (pressure head = 0)
388
- if phreatic:
389
- elevation = nodes[:, 1] # y-coordinate is elevation
390
- pressure_head = head - elevation
391
- ax.tricontour(triang, pressure_head, levels=[0], colors="red", linewidths=2.0)
392
-
393
- # Overlay flowlines if phi is available
394
- if phi is not None and flowrate is not None and k1_by_mat is not None:
461
+ # Solid lines for contours
462
+ ax.tricontour(triang, contour_data, levels=contour_levels, colors="k", linewidths=0.5)
463
+
464
+ # Phreatic surface (pressure head = 0) - only for head plots
465
+ # Check if phreatic surface exists (pore pressure must be negative somewhere)
466
+ has_phreatic = False
467
+ if phreatic and plot_flowlines:
468
+ # Check if pore pressure goes negative (indicating a phreatic surface exists)
469
+ u = solution.get("u")
470
+ if u is not None and np.min(u) < 0:
471
+ elevation = nodes[:, 1] # y-coordinate is elevation
472
+ pressure_head = head - elevation
473
+ ax.tricontour(triang, pressure_head, levels=[0], colors="red", linewidths=2.0)
474
+ has_phreatic = True
475
+
476
+ # Overlay flowlines if variable is head and phi is available
477
+ if plot_flowlines and phi is not None and flowrate is not None and k1_by_mat is not None:
478
+ # Compute head drop for flowline calculation
479
+ hdrop = vmax - vmin
395
480
  if base_mat > len(k1_by_mat):
396
481
  print(f"Warning: base_mat={base_mat} is larger than number of materials ({len(k1_by_mat)}). Using material 1.")
397
482
  base_mat = 1
@@ -406,8 +491,39 @@ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat
406
491
  phi_contours = np.linspace(np.min(phi), np.max(phi), phi_levels)
407
492
  ax.tricontour(triang, phi, levels=phi_contours, colors="blue", linewidths=0.7, linestyles="solid")
408
493
 
494
+ # Plot velocity vectors if requested
495
+ if vectors:
496
+ velocity = solution.get("velocity")
497
+ if velocity is not None:
498
+ # Calculate x_range for scaling
499
+ x_min_vec = nodes[:, 0].min()
500
+ x_max_vec = nodes[:, 0].max()
501
+ x_range = x_max_vec - x_min_vec
502
+ max_vector_length = x_range * vector_scale
503
+
504
+ # Get velocity magnitude
505
+ v_mag = solution.get("v_mag")
506
+ if v_mag is None:
507
+ # Calculate v_mag if not available
508
+ v_mag = np.linalg.norm(velocity, axis=1)
509
+
510
+ # Find maximum velocity magnitude
511
+ max_v_mag = np.max(v_mag)
512
+
513
+ # Scale vectors: if max_v_mag > 0, scale so max vector has length max_vector_length
514
+ if max_v_mag > 0:
515
+ scale_factor = max_vector_length / max_v_mag
516
+ velocity_scaled = velocity * scale_factor
517
+ else:
518
+ velocity_scaled = velocity
519
+
520
+ # Plot vectors using quiver
521
+ ax.quiver(nodes[:, 0], nodes[:, 1], velocity_scaled[:, 0], velocity_scaled[:, 1],
522
+ angles='xy', scale_units='xy', scale=1, width=0.002, headwidth=2.5,
523
+ headlength=3, headaxislength=2.5, color='black', alpha=0.7)
524
+
409
525
  # Plot element edges if requested
410
- if show_mesh:
526
+ if mesh:
411
527
  # Draw all element edges
412
528
  for element, elem_type in zip(elements, element_types if element_types is not None else [3]*len(elements)):
413
529
  if elem_type == 3:
@@ -444,13 +560,17 @@ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat
444
560
  ax.set_xlim(x_min - x_pad, x_max + x_pad)
445
561
  ax.set_ylim(y_min - y_pad, y_max + y_pad)
446
562
 
447
- title = "Flow Net: Head Contours"
448
- if phi is not None:
449
- title += " and Flowlines"
450
- if phreatic:
451
- title += " with Phreatic Surface"
452
- if flowrate is not None:
453
- title += f" Total Flowrate: {flowrate:.3f}"
563
+ # Build title based on variable
564
+ if variable == "head":
565
+ title = f"Flow Net: {variable_label} Contours"
566
+ if plot_flowlines and phi is not None:
567
+ title += " and Flowlines"
568
+ if has_phreatic:
569
+ title += " with Phreatic Surface"
570
+ if flowrate is not None:
571
+ title += f" — Total Flowrate: {flowrate:.3f}"
572
+ else:
573
+ title = f"{variable_label} Contours"
454
574
  ax.set_title(title)
455
575
 
456
576
  # Set equal aspect ratio AFTER setting limits