The Balance Point
Placing a Support Area with the Weighted Center of Gravity
“Strategy decides where to act; logistics brings the troops to that point.” — attributed to Antoine-Henri Jomini.
Most of this series has chased big, abstract machinery. This article does the opposite. It takes one small, concrete question every logistician faces, where do I put the support area, and shows how a little arithmetic beats a lot of thumb-on-the-map. A fellow Army officer, James Henry, and I published a Microsoft Excel tool for exactly this in Army Sustainment (July to August 2014). What follows revisits our method, strips it to its core, and ports it to Python, where it grows a few new tricks.
The problem is universal to sustainment. A brigade support battalion, or any support unit, wants to sit close to the maneuver units it feeds and close to the supply nodes it draws from, the railheads and transfer points. It cannot be close to everything at once, and some customers matter more than others. So we need a principled way to split the difference, one that leans toward what matters most and stays out of trouble.
The Balance-Pole Idea
James and I reached for a physical analogy to portray our methodology. Imagine a weightless pole with a weight for each location you care about. Where does it balance? If every weight is equal, it balances in the middle. Hang a heavier weight on one end and the balance point slides toward it. That balance point is the center of mass, and in one dimension it is a weighted average, nothing more:
The weights are where command intent enters the math. Following the original article, a main-effort unit is weighted heavier than a supporting-effort unit, and a primary supply route heavier than an alternate. Adjust those numbers and you steer the answer: nudge a unit’s weight up and the recommended location drifts toward it, exactly as a commander’s priorities should.
A Tiny Example
Strip it to the bone: one supported unit, one supply node, one enemy. The following example may be a bit odd (enemy separating the BSB from the TF) but it drives the idea of how the math balances all concerns. (Pun intended) Our maneuver unit, TF Anvil, sits at grid (40, 28) and rates a weight of 0.6. Our railhead sits at (4, 6) and rates 0.4. The balance point is just the weighted average of the two, which lands at (25.6, 19.2), about three-fifths of the way from the railhead out toward the unit, precisely as the weighting demands.
Now the twist that makes it military rather than merely geometric. That balance point happens to sit inside the enemy’s fires range, 6.5 km from a threat that reaches 8. A support area is not a fort; putting one under enemy fire is how you lose it. So we impose a hard constraint, stay outside the ring, and the tool slides the location to the nearest point that satisfies it, about 1.5 km away on the edge of the fan. Balance first, then step clear of the threat. That is the entire concept, and you could almost do it with a ruler, but when does a logistician have so few things to worry about?
From Balance Point to Least Travel
The balance-pole average is the intuition. The honest objective is a fair bit different, and worth the distinction, because trucks pay in miles driven, not in miles squared. So rather than the average, we minimize the total weighted distancefrom the support area to every location, each scaled by its weight. That is the classical Weber problem, the granddaddy of facility location:
For a real laydown of many units and nodes the two answers sit near each other, but the Weber point is the one that actually minimizes convoy mileage, so it is the one the tool solves. In the original model, James and I flagged a subtle trap: the Excel solver can roll into the nearest low spot and miss a better one over the ridge, the local-minimum problem that haunts every method in this series on optimization. The Python version cures it by trying many starting points and keeping the best, so the recommendation is the true minimum, not merely a nearby one.
The Whole Laydown
Scale the tiny example up to a full staff estimate and nothing about the idea changes, only the number of points. Here are several supported units split into main and supporting efforts, two supply nodes on primary and alternate routes, and a set of enemy systems whose ranges the support area must avoid, including a deep-fires threat that reaches into the rear:
The recommendation comes back in both latitude and longitude and in the Military Grid Reference System (MGRS), so it drops straight onto whatever the staff is already reading. Note the deep-fires threat in the rear: the unconstrained balance point, the hollow star, would have parked the support area right under it. The tool pushed the location forward, off the ring, at a cost of barely a kilometer of extra weighted travel. Cheap insurance.
Why Python, and Why Now
The 2014 tool lived in Excel because Excel was on every staff laptop. That was the right call then, and the Solver add-in did honest work. Python simply removes the ceilings. Reading grids as MGRS is a solved problem, a small free library converts to and from latitude and longitude in one call, so the staff can enter coordinates in whichever they prefer and read the answer in either. The inputs still live in a spreadsheet the staff already knows how to edit; Python just reads it.
Get William (Bill) Smith, Ph.D.’s stories in your inbox
Join Medium for free to get updates from this writer.
And the room to grow is real. The same few lines that draw the static map here can drive an interactive map you pan and zoom, with units you drag to see the recommendation move live; a lightweight web page the whole staff can open in a browser; or a batch run that scores a dozen courses of action against each other before the brief. None of that requires new math. It is the same balance point, wearing better clothes, and all of it is free.
Try It Yourself: Find the Balance
The core is one line of arithmetic. The companion tool wraps that in the least-travel objective, the enemy keep-out, the MGRS conversion, and the map, and it reads its inputs from a plain spreadsheet so no one has to touch the code. Python 3.10 and up; a couple of small free libraries.
# the balance point is a weighted average -- one line of arithmetic
import numpy as np
units = np.array([[40, 28], # TF Anvil (supported unit)
[ 4, 6]]) # Railhead (MSR node)
weights = np.array([0.6, 0.4]) # priority: lean toward the unit
balance = (weights[:, None] * units).sum(0) / weights.sum()
print(balance) # -> [25.6 19.2]
# the full tool adds least-distance routing, the enemy keep-out, MGRS,
# and a labeled map. One line at the terminal:
# python support_area_optimizer.py units_example.csvWhere It Fits
This is the single-facility location problem, the center-of-gravity method that logisticians and distribution planners have used for a century to place a warehouse, a depot, or a support area. It is the gentlest member of the facility-location family this series will visit, and a fine on-ramp to the harder ones. Its limits are honest and worth stating plainly. It weighs distance, not roads, terrain, or cover; it recommends a point, not a position you could actually occupy; and its answer is only as good as the weights you feed it. Quoting George Box, “All models are wrong, but some are useful.” This one is useful. It hands the staff a defensible starting grid in seconds, and leaves the final call, and the METT-TC judgment it rests on, where it belongs.
The best decision-support tools do not replace judgment; they organize it. This one takes something a staff already argues about with a grease pencil, what to stay near and how much, and turns it into a number anyone can compute, check, and challenge. That is not a small thing. A recommendation you can defend on a napkin is worth more than an optimum no one understands.
The methods in this series get more elaborate from here, but few are more useful at the company and battalion level than this one. It asks for nothing but grid coordinates a staff already has, it answers in the grid they already read, and it turns a wall-map argument into a number anyone can check. Sometimes the whole art of optimization is just weighing what matters and finding where it balances.
The companion support_area_optimizer.py, with its quickstart.md and an editable units_example.csv, is the whole thing: fill in your units, nodes, and threats in the spreadsheet, run one line, and read the recommended grid in lat/long and MGRS with a map to back it up. Change a weight and watch the location lean; add an enemy and watch it step back.
support_area_optimizer.py:
"""
support_area_optimizer.py
==========================================================================
A Python port of the Excel support-area tool from
Henry, J. and Smith, W., "A tool to assist with emplacing support units
on the battlefield," Army Sustainment, Jul-Aug 2014.
https://www.army.mil/article/128694
WHAT IT DOES
------------
Given the locations of the units you support (customers) and the supply
nodes you draw from (railheads, ports, transfer points), it finds the spot
for your support area (e.g., a BSB) that MINIMIZES the weighted travel
distance to all of them. Priority units are weighted heavier so the answer
leans toward them, just like balancing weights on a pole. It also keeps the
recommended spot OUTSIDE enemy weapons range and within any min/max distance
bands you set.
WHAT'S NEW VS. THE 2014 SPREADSHEET
-----------------------------------
* Pure Python, no Excel Solver.
* Enter locations as latitude/longitude OR MGRS grid, and switch freely.
The answer is printed in BOTH.
* Distance is user-selectable: great-circle (matches the original) or
planar UTM meters (grid-native).
* A multi-start solver cures the "local minimum" trap the article warned
about: it tries many starting points and keeps the best feasible one.
* A labeled map is drawn automatically.
REQUIREMENTS
------------
pip install numpy scipy matplotlib mgrs utm
Python 3.10+.
"All models are wrong, but some are useful." -- George E. P. Box
==========================================================================
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
import mgrs
import utm
from scipy.optimize import minimize
_M = mgrs.MGRS()
# --------------------------------------------------------------------------
# Coordinates: accept lat/long OR MGRS, keep lat/long as the master format.
# --------------------------------------------------------------------------
def parse_location(value) -> tuple[float, float]:
"""Return (lat, lon) from either a (lat, lon) pair or an MGRS string.
>>> parse_location("18STG9258724403") # MGRS -> lat/lon
>>> parse_location((37.2431, -77.3384)) # lat/lon passes through
"""
if isinstance(value, str):
lat, lon = _M.toLatLon(value.replace(" ", "").upper())
return float(lat), float(lon)
lat, lon = value
return float(lat), float(lon)
def to_mgrs(lat: float, lon: float, precision: int = 4) -> str:
"""lat/lon -> MGRS. precision 4 = 10 m, 3 = 100 m, 2 = 1 km."""
return _M.toMGRS(lat, lon, MGRSPrecision=precision)
def _utm_projector(points):
"""Build a lat/lon -> (easting, northing) projector locked to ONE UTM zone
(the zone of the mean longitude), so planar distances are consistent."""
mean_lat = np.mean([p[0] for p in points])
mean_lon = np.mean([p[1] for p in points])
_, _, zone, _ = utm.from_latlon(mean_lat, mean_lon)
def project(lat, lon):
e, n, _, _ = utm.from_latlon(lat, lon, force_zone_number=zone)
return e, n
return project
# --------------------------------------------------------------------------
# Distances (both return kilometers).
# --------------------------------------------------------------------------
def haversine_km(a, b) -> float:
(lat1, lon1), (lat2, lon2) = a, b
R = 6371.0088
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(h))
# --------------------------------------------------------------------------
# Problem definition
# --------------------------------------------------------------------------
@dataclass
class Point:
name: str
group: str # "customer" or "node"
category: str # "ME"/"SE" for customers, "MSR"/"ASR" for nodes
lat: float
lon: float
priority: float = 1.0 # relative weight within its category group
dmin: float = 0.0 # keep support area at least this far (km)
dmax: float = 1e9 # ...and at most this far (km)
@dataclass
class Enemy:
name: str
lat: float
lon: float
range_km: float # stay OUTSIDE this radius
# category priority multipliers (relative; the article's scheme)
DEFAULT_CATEGORY_WEIGHT = {"ME": 0.625, "SE": 0.375, "MSR": 0.625, "ASR": 0.375}
@dataclass
class Result:
lat: float
lon: float
mgrs: str
objective: float
feasible: bool
per_point: list = field(default_factory=list) # (name, dist_km, weight)
violations: list = field(default_factory=list)
# --------------------------------------------------------------------------
# Weights: global (customer vs node) x category (ME/SE, MSR/ASR), normalized.
# --------------------------------------------------------------------------
def composite_weights(points, global_customer=0.5, global_node=0.5):
w = {}
for grp, gw in (("customer", global_customer), ("node", global_node)):
grp_pts = [p for p in points if p.group == grp]
raw = {p.name: p.priority * DEFAULT_CATEGORY_WEIGHT.get(p.category, 1.0)
for p in grp_pts}
s = sum(raw.values()) or 1.0
for p in grp_pts:
w[p.name] = gw * raw[p.name] / s # each group sums to its global weight
return w
# --------------------------------------------------------------------------
# The solver: multi-start, constrained.
# --------------------------------------------------------------------------
def solve(points, enemies=(), distance="greatcircle",
global_customer=0.5, global_node=0.5, n_starts=60, seed=0):
"""Find the weighted-distance-minimizing support-area location.
distance : "greatcircle" (km, haversine) or "planar" (km, UTM meters/1000).
Returns a Result. If no location satisfies every constraint, returns the
least-infeasible point with feasible=False and the list of violations.
"""
latlons = [(p.lat, p.lon) for p in points] + [(e.lat, e.lon) for e in enemies]
project = _utm_projector(latlons)
w = composite_weights(points, global_customer, global_node)
weights = np.array([w[p.name] for p in points])
if distance == "planar":
pts_xy = np.array([project(p.lat, p.lon) for p in points]) / 1000.0
en_xy = np.array([project(e.lat, e.lon) for e in enemies]) / 1000.0 if enemies else None
def d_to_points(xy):
return np.hypot(*(pts_xy - xy).T)
def d_to_enemy(xy, j):
return math.hypot(*(en_xy[j] - xy))
def unproject(xy): # meters -> lat/lon
e0, n0, zone, _ = utm.from_latlon(np.mean([p.lat for p in points]),
np.mean([p.lon for p in points]))
return utm.to_latlon(xy[0] * 1000, xy[1] * 1000, zone,
northern=np.mean([p.lat for p in points]) >= 0)
start_space = pts_xy
else:
def d_to_points(ll):
return np.array([haversine_km((ll[0], ll[1]), (p.lat, p.lon)) for p in points])
def d_to_enemy(ll, j):
return haversine_km((ll[0], ll[1]), (enemies[j].lat, enemies[j].lon))
def unproject(ll):
return ll[0], ll[1]
start_space = np.array([(p.lat, p.lon) for p in points])
def objective(v):
return float(np.dot(weights, d_to_points(v)))
# inequality constraints g(v) >= 0
cons = []
for i, p in enumerate(points):
if p.dmin > 0:
cons.append({"type": "ineq", "fun": (lambda v, i=i, p=p: d_to_points(v)[i] - p.dmin)})
if p.dmax < 1e8:
cons.append({"type": "ineq", "fun": (lambda v, i=i, p=p: p.dmax - d_to_points(v)[i])})
for j, e in enumerate(enemies):
cons.append({"type": "ineq", "fun": (lambda v, j=j, e=e: d_to_enemy(v, j) - e.range_km)})
# Weiszfeld center (great starting point) in the working space
center = start_space.mean(axis=0)
for _ in range(200):
d = np.maximum(np.hypot(*(start_space - center).T), 1e-9)
center = (weights[:, None] * start_space / d[:, None]).sum(0) / (weights / d).sum()
rng = np.random.default_rng(seed)
lo, hi = start_space.min(0), start_space.max(0)
span = np.maximum(hi - lo, 1e-6)
starts = [center, start_space.mean(0)] + \
[lo + span * rng.random(2) for _ in range(n_starts)]
best, best_val = None, np.inf
for s0 in starts:
r = minimize(objective, s0, method="SLSQP", constraints=cons,
options={"maxiter": 300, "ftol": 1e-7})
v = r.x
viol = _violations(v, points, enemies, d_to_points, d_to_enemy)
score = objective(v) + 1e6 * sum(m for _, m in viol) # punish infeasibility
if score < best_val:
best, best_val, best_viol = v, score, viol
lat, lon = unproject(best)
per = [(p.name, float(d_to_points(best)[i]), float(weights[i]))
for i, p in enumerate(points)]
return Result(lat=lat, lon=lon, mgrs=to_mgrs(lat, lon),
objective=objective(best), feasible=(len(best_viol) == 0),
per_point=per, violations=[n for n, _ in best_viol])
def _violations(v, points, enemies, d_to_points, d_to_enemy, tol=1e-3):
out = []
dp = d_to_points(v)
for i, p in enumerate(points):
if dp[i] < p.dmin - tol:
out.append((f"{p.name}: too close ({dp[i]:.1f} < min {p.dmin:.1f} km)", p.dmin - dp[i]))
if dp[i] > p.dmax + tol:
out.append((f"{p.name}: too far ({dp[i]:.1f} > max {p.dmax:.1f} km)", dp[i] - p.dmax))
for j, e in enumerate(enemies):
de = d_to_enemy(v, j)
if de < e.range_km - tol:
out.append((f"{e.name}: inside range ({de:.1f} < {e.range_km:.1f} km)", e.range_km - de))
return out
# --------------------------------------------------------------------------
# Reporting + map
# --------------------------------------------------------------------------
def report(result: Result, distance_label: str) -> str:
lines = ["=" * 62,
"RECOMMENDED SUPPORT-AREA LOCATION",
"=" * 62,
f" Lat/Long : {result.lat:.5f}, {result.lon:.5f}",
f" MGRS : {result.mgrs}",
f" Weighted {distance_label} distance (objective): {result.objective:.2f} km",
f" Feasible : {'YES' if result.feasible else 'NO -- constraints violated'}"]
if not result.feasible:
lines.append(" Violations (relax these or rethink the plan):")
lines += [f" - {v}" for v in result.violations]
lines.append("-" * 62)
lines.append(f" {'Location':22s}{'dist (km)':>11s}{'weight':>9s}")
for name, d, wt in sorted(result.per_point, key=lambda t: -t[2]):
lines.append(f" {name:22s}{d:>11.1f}{wt:>9.3f}")
lines.append("=" * 62)
return "\n".join(lines)
def draw_map(points, enemies, result, fname="support_area_map.png",
title="Recommended support-area location", ideal=None):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
project = _utm_projector([(p.lat, p.lon) for p in points]
+ [(e.lat, e.lon) for e in enemies]
+ [(result.lat, result.lon)])
def xy(lat, lon):
e, n = project(lat, lon)
return e / 1000.0, n / 1000.0
style = {"ME": ("#c1121f", "o", "Main effort (ME)"),
"SE": ("#e9a800", "o", "Supporting effort (SE)"),
"MSR": ("#0353a4", "s", "Supply node (MSR)"),
"ASR": ("#6a4c93", "s", "Supply node (ASR)")}
fig, ax = plt.subplots(figsize=(8.2, 6.4))
seen = set()
for p in points:
c, mk, lab = style.get(p.category, ("#444", "o", p.category))
x, y = xy(p.lat, p.lon)
ax.scatter([x], [y], c=c, marker=mk, s=130, edgecolors="white", zorder=5,
label=lab if lab not in seen else None)
seen.add(lab)
ax.annotate(p.name, (x, y), textcoords="offset points", xytext=(7, 5),
fontsize=7.5, color="#1b2a41")
for e in enemies:
x, y = xy(e.lat, e.lon)
ax.add_patch(Circle((x, y), e.range_km, fc="#c1121f", ec="#c1121f",
alpha=0.10, zorder=1))
ax.scatter([x], [y], c="black", marker="X", s=120, zorder=5,
label="Enemy (keep-out)" if "Enemy (keep-out)" not in seen else None)
seen.add("Enemy (keep-out)")
ax.annotate(f"{e.name}\n({e.range_km:.0f} km)", (x, y),
textcoords="offset points", xytext=(7, 5), fontsize=7, color="black")
if ideal is not None:
ix, iy = xy(ideal[0], ideal[1])
rx0, ry0 = xy(result.lat, result.lon)
ax.plot([ix, rx0], [iy, ry0], color="#2a9d8f", ls="--", lw=1.2, zorder=4)
ax.scatter([ix], [iy], facecolors="none", edgecolors="#2a9d8f", marker="*",
s=430, linewidths=1.6, zorder=6,
label="ideal spot (ignores threat)")
rx, ry = xy(result.lat, result.lon)
ax.scatter([rx], [ry], c="#2a9d8f", marker="*", s=520, edgecolors="white",
linewidths=1.4, zorder=6, label="SUPPORT AREA (displaced clear)")
ax.annotate(result.mgrs, (rx, ry), textcoords="offset points", xytext=(10, -14),
fontsize=8, fontweight="bold", color="#1b2a41")
ax.set_xlabel("Easting (km)"); ax.set_ylabel("Northing (km)")
ax.set_title(title, color="#1b2a41")
ax.set_aspect("equal"); ax.grid(True, color="#d9dce1", lw=0.7)
ax.legend(fontsize=8, loc="best")
fig.tight_layout(); fig.savefig(fname, dpi=170, facecolor="white")
plt.close(fig)
return fname
# --------------------------------------------------------------------------
# Load a scenario from a CSV so you can work in a spreadsheet, not code.
# Columns: name, role, category, lat, lon, mgrs, priority, min_km, max_km,
# enemy_range_km. Give EITHER lat+lon OR mgrs. Blank cells are fine.
# role : customer | node | enemy
# category : ME/SE (customers), MSR/ASR (nodes), blank for enemy
# --------------------------------------------------------------------------
def _row_latlon(row):
if (row.get("mgrs") or "").strip():
return parse_location(row["mgrs"].strip())
return float(row["lat"]), float(row["lon"])
def _num(row, key, default):
v = (row.get(key) or "").strip()
return float(v) if v else default
def load_scenario(path):
"""Read points and enemies from a CSV. Returns (points, enemies)."""
import csv
pts, ens = [], []
with open(path, newline="") as f:
for row in csv.DictReader(f):
name = (row.get("name") or "").strip()
if not name or name.startswith("#"):
continue
role = (row.get("role") or "").strip().lower()
lat, lon = _row_latlon(row)
if role == "enemy":
ens.append(Enemy(name, lat, lon, _num(row, "enemy_range_km", 0.0)))
else:
pts.append(Point(name, role, (row.get("category") or "").strip().upper(),
lat, lon, priority=_num(row, "priority", 1.0),
dmin=_num(row, "min_km", 0.0),
dmax=_num(row, "max_km", 1e9)))
return pts, ens
# --------------------------------------------------------------------------
# Worked example (notional AO). Edit these, or load from CSV (see quickstart).
# --------------------------------------------------------------------------
def example_problem():
points = [
Point("TF Iron (ME)", "customer", "ME", 37.46, -77.28, dmin=8, dmax=60),
Point("TF Steel (ME)", "customer", "ME", 37.39, -77.08, dmin=8, dmax=60),
Point("TF Ghost (SE)", "customer", "SE", 37.18, -77.52, dmin=8, dmax=70),
Point("TF Raider (SE)", "customer", "SE", 37.12, -77.14, dmin=8, dmax=70),
Point("Railhead (MSR)", "node", "MSR", 37.33, -77.74, dmin=5, dmax=80),
Point("ASP (ASR)", "node", "ASR", 37.06, -77.86, dmin=5, dmax=90),
]
enemies = [
Enemy("Enemy SSM", 37.58, -76.94, range_km=22),
Enemy("Enemy MLRS", 37.02, -77.00, range_km=28),
# a long-range fires threat that reaches the rear: it ranges the spot the
# weighted math would otherwise pick, so the tool must displace clear.
Enemy("Enemy LR fires", 37.26, -77.68, range_km=20),
]
return points, enemies
def main():
import sys
if len(sys.argv) > 1: # run YOUR scenario: ... units.csv [planar]
points, enemies = load_scenario(sys.argv[1])
mode = sys.argv[2] if len(sys.argv) > 2 else "greatcircle"
res = solve(points, enemies, distance=mode)
print(report(res, mode))
draw_map(points, enemies, res)
print(" (map written to support_area_map.png)")
return
points, enemies = example_problem()
# the "ideal spot" ignores the enemy entirely, to show what the keep-out costs
ideal = solve(points, enemies=(), distance="greatcircle")
for mode, label in [("greatcircle", "great-circle"), ("planar", "planar-UTM")]:
res = solve(points, enemies, distance=mode)
print(report(res, label))
if mode == "greatcircle":
draw_map(points, enemies, res, ideal=(ideal.lat, ideal.lon))
print(" (map written to support_area_map.png)")
print(f" ideal spot ignoring threat: {ideal.mgrs} "
f"(objective {ideal.objective:.2f} km); the keep-out costs "
f"{res.objective - ideal.objective:.2f} km of extra travel.")
print()
# demonstrate the MGRS <-> lat/long toggle on the answer
res = solve(points, enemies, distance="greatcircle")
print("Same recommendation, either way you like to read a map:")
print(f" lat/long -> {res.lat:.5f}, {res.lon:.5f}")
print(f" MGRS -> {res.mgrs}")
print(f" MGRS back to lat/long -> {parse_location(res.mgrs)}")
if __name__ == "__main__":
main()quick_start.md:
# Support-Area Optimizer — Quick Start
A Python version of the Excel tool from Henry and Smith, *"A tool to assist with
emplacing support units on the battlefield"* (Army Sustainment, Jul–Aug 2014).
You give it the units you support and the supply nodes you draw from. It hands
back the spot for your support area (say, a BSB) that keeps the **weighted
travel distance** to everyone as small as possible, while staying **outside
enemy weapons range** and inside any distance limits you set. You get the answer
in both **lat/long and MGRS**, plus a labeled map.
Think of it as the balance-pole idea from the article: weight the units you most
need to be near, and the balance point slides toward them.
---
## 1. Install (one time)
You need Python 3.10 or newer. Then, in a terminal:
```
pip install numpy scipy matplotlib mgrs utm
```
That is it. No Excel, no Solver add-in.
## 2. Put your units in the spreadsheet
Open **`units_example.csv`** in Excel (or any spreadsheet), save your own copy,
and edit the rows. One row per unit, node, or enemy. The columns:
| column | what to put |
|---|---|
| `name` | anything, e.g., `TF Iron (ME)` |
| `role` | `customer`, `node`, or `enemy` |
| `category` | `ME` or `SE` for customers; `MSR` or `ASR` for nodes; leave blank for enemy |
| `lat`, `lon` | the location in decimal degrees **— OR —** |
| `mgrs` | the location as an MGRS string (fill in `lat`/`lon` *or* `mgrs`, not both) |
| `priority` | relative importance within its group (default `1`) |
| `min_km`, `max_km` | keep the support area at least / at most this far from this unit |
| `enemy_range_km` | for an enemy row only: the radius you must stay outside |
You can mix lat/long and MGRS freely between rows. In the example, `TF Ghost` is
given in MGRS and everyone else in lat/long, on purpose, to show it works.
## 3. Run it
```
python support_area_optimizer.py units_example.csv
```
Want grid-native straight-line distance instead of great-circle? Add `planar`:
```
python support_area_optimizer.py units_example.csv planar
```
With no file at all, it runs a built-in notional example so you can see the
output shape:
```
python support_area_optimizer.py
```
## 4. Read the answer
You get a report like this:
```
RECOMMENDED SUPPORT-AREA LOCATION
Lat/Long : 37.29405, -77.45805
MGRS : 18STG82113032
Weighted great-circle distance (objective): 29.93 km
Feasible : YES
Location dist (km) weight
Railhead (MSR) 25.3 0.312
...
```
and a map, **`support_area_map.png`**, showing your units, the enemy keep-out
rings, the recommended location (solid star), and the spot the math would have
picked if it ignored the threat (hollow star). The dashed line between them is
the displacement the enemy forced on you.
---
## Adjusting the recommendation
**Pull it toward your customers or toward your supply.** In the code, `solve(...)`
takes `global_customer` and `global_node` (they default to `0.5` and `0.5`,
matching the article). Raise `global_customer` to lean toward the maneuver units.
**Change who matters most.** Main effort is weighted heavier than supporting
effort (`0.625` vs `0.375`), and MSR heavier than ASR, exactly as in the article.
Bump a single unit's `priority` in the CSV to pull the answer toward it.
**Tighten the geometry.** Use `min_km` / `max_km` to force a standoff or a
reach, and `enemy_range_km` to grow a threat ring.
## Two things worth knowing
**The local-minimum trap is handled.** The article warned that Solver could
settle into the nearest valley and miss a better one over the ridge. This tool
tries many starting points and keeps the best, so you get the true best location,
not just a nearby one.
**If it says `Feasible : NO`, the constraints cannot all be met at once.** It
still returns the least-bad point and lists what it violated. That is your cue to
relax a distance band or accept a little more risk, precisely the judgment call
the original article describes.
---
This tool gives a defensible **starting** location from the quantitative factors. The final
position still comes from your staff weighing METT-TC.Finally, you’ll need to create a unit_example.csv file that looks like the following:







