Skip to content

Center of Gravity

Find the demand-weighted centre of your locations on Earth's surface. Each location becomes a three-dimensional unit vector; their weighted average is projected back onto the sphere and returned as latitude and longitude. A large customer pulls the centre more than a small one, and locations across the date line average on the correct side of Earth.

When to use this

  • You're deciding where to open a new warehouse or DC
  • You want to check whether your current facility locations are sensible
  • You're expanding the network and need to pick the next location

What you need

  • Location records with latitude and longitude, from Map Locations
  • Demand policy rows with a Requested Quantity, from Map Demand Policy. They are the weights, and they are required
  • A scenario that states its Unit on Create Scenario. Each demand point is weighed in that unit: a quantity in bags or pallets is converted with its product's conversion, as the optimizer converts it, so 10 pallets weigh what 10 pallets weigh and not what 10 bags do. A scenario with no unit, or a quantity no conversion reaches, is refused with the product and units named

Settings

Setting Options Default What it does
Location Mode Propose New Locations, Use Existing Locations, Expand Network Propose New Locations Propose New Locations computes brand-new coordinates. Use Existing Locations picks the locations you already have that together serve demand best. Expand Network keeps the current facilities and adds new ones.
Maximum Locations a whole number from 1 to 100 1 How many facilities to find. With Expand Network it is titled New Locations to Add and counts only the new ones.
Include Location Types any types in your data all of them Limit the analysis to specific location types — only retailers, say. Only their demand is weighed, and with Use Existing Locations only they are candidate sites. With Expand Network it is titled Demand Location Types and chooses only whose demand places the new facilities.
Existing Facility Types any types in your data none For Expand Network: the location types that stay where they are. They are found among all your locations, whatever Demand Location Types lists.

How each mode places sites

  • Propose New Locations calculates the spherical weighted centroid. With one site, all included demand contributes to that average. With more, each customer is assigned to its nearest site, each site moves to its group's centroid, and assignments and placements repeat until stable.
  • Use Existing Locations picks that many of the scenario's locations as a set, each demand point counted towards its nearest pick. It adds the best location one at a time, then swaps a pick for another location while a swap lowers the total weighted distance.
  • Expand Network keeps the facilities of the Existing Facility Types in place, seeds new sites in demand far from those facilities, and then repeats customer assignment and centroid calculation for only the new sites.

A single centroid is calculated directly, without an iterative search. If the weighted vectors cancel, their average has no stable direction: the node reports an undefined centre instead of inventing a location. For example, equal demand at antipodal points is refused. At a pole, the result uses longitude zero.

Multiple-site and expansion modes are clustering heuristics. Their converged flag says placements have stabilized and is false if the iteration budget is exhausted. A centroid is a geographic average; it does not claim to minimize transportation distance or cost.

What you get

The scenario it was given, with the answer added as a cog-results table: one row per site, with its name and coordinates, whether it is a new site, a facility kept in place or an existing location picked, and the weighted distance of the demand it serves. A Create Scenario after this node saves the answer with the scenario, where your agent inspects and queries it like any other table of the scenario — how many sites were found, and the total weighted distance to them, which is the sum of the rows' weighted distances. The distance is the great-circle separation in kilometres weighted by demand. It describes the returned sites; it is not a road distance, cost or objective optimized by the centroid calculation.

Workflow wiring

This node takes scenario data — wire it after Create Scenario, Load Scenario, or another analysis step. Do not wire it straight to a Map node.

flowchart LR
    n1["Import Data (locations.csv)"] --> n2["Map Locations"]
    n3["Create Scenario"] --> n4["Center of Gravity"]
    n5["Import Data (demand.csv)"] --> n6["Map Demand Policy"]
    n2 --> n3
    n6 --> n3
    n7["Import Data (products.csv)"] --> n8["Map Products"]
    n8 --> n3
    n4 --> n9["Create Scenario"]

Location records must carry latitude and longitude. The demand policy supplies the weights through its Requested Quantity.

Common mistakes

  • Wiring this node to a Map node instead of through Create Scenario
  • Leaving out the Create Scenario after it. The answer rides on the scenario, and a workflow with nothing downstream to save it is refused before it runs
  • Missing latitude or longitude on location records — the run fails
  • Asking for more than 100 locations, or fewer than 1. The panel refuses the number
  • Providing no demand. A demand policy with a Requested Quantity is required: this node weights by demand and does not invent equal demand weights. A demand policy whose Requested Quantity is zero at every included location is refused the same way
  • Choosing Expand Network and leaving Existing Facility Types empty. Expand keeps existing facilities in place and needs to be told which ones; with nothing named it finds none and the run fails
  • Naming a type in Include Location Types, Demand Location Types or Existing Facility Types that no location in the scenario has. The run refuses it and names the setting, and checking the analysis readiness reports it before a run

What it does not do

  • It computes coordinates only. It does not modify the scenario's locations, links or flows.
  • Proposed locations arrive as an analysis result beside the scenario. They are not part of the network: no lanes, no supply, no cost.
  • So it cannot answer what is the cost impact of a new warehouse? on its own. For that, bridge it into Network Optimization.

Chaining into Network Optimization

To evaluate the cost and flow impact of the proposed locations, chain three steps:

flowchart LR
    n1["Load Scenario"] --> n2["Center of Gravity"]
    n2 --> n3["Scenario Script (bridge, into Scenario Data)"]
    n3 --> n4["Network Optimization"]
    n4 --> n5["Create Scenario"]

What the result holds

A script reads the result as scenario_data["_cog_result"]["optimal_locations"]. Each entry's result_type says what it is:

result_type Mode that returns it What it is
proposed Propose New Locations, Expand Network A proposed facility; its coordinates may coincide with an existing location.
fixed Expand Network A facility you already have and kept in place. source_location_id names it.
existing Use Existing Locations A facility you already have, picked as one of the best. source_location_id names it.

Only proposed entries are new. Adding a fixed or existing entry as a location duplicates a facility that is already in the network.

The bridge script

The script adds each proposed site to the scenario's locations and gives it lanes, so the optimizer can route through it. A new site has no lanes or prices of its own, so the script copies them from existing facilities. These are the model facilities: by default the ones Center of Gravity kept fixed. The new site gets:

  • the same counterparts as the model facilities: whoever supplies them, and whoever they deliver to
  • the same products and transport modes on those lanes
  • the same cost per km: for each direction, transport mode and quantity_unit, the median unit_cost per km over the model facilities' own lanes. Rates in different units are never pooled, and each new lane states the quantity_unit its rate is in, which Network Optimization requires of every priced lane
  • the model facilities' location type, so they must all share one

With Propose New Locations nothing is fixed. List the facilities the new site should work like in MODEL_ON. The script refuses to guess, and it stops when there is nothing proposed, the model facilities are of more than one type, or they have no priced lane in a direction to take a rate from.

def main(scenario_data):
    """Add Center of Gravity's proposed sites to the network, modelled on existing facilities."""
    import math
    import statistics

    # Ids of existing facilities the new sites should work like. Empty means
    # the facilities Center of Gravity kept fixed (Expand Network).
    MODEL_ON = []

    def haversine_km(lat1, lon1, lat2, lon2):
        R = 6371
        dlat = math.radians(lat2 - lat1)
        dlon = math.radians(lon2 - lon1)
        a = (
            math.sin(dlat / 2) ** 2
            + math.cos(math.radians(lat1))
            * math.cos(math.radians(lat2))
            * math.sin(dlon / 2) ** 2
        )
        return R * 2 * math.asin(math.sqrt(a))

    entries = scenario_data.get("_cog_result", {}).get("optimal_locations", [])
    proposed = [e for e in entries if e.get("result_type") == "proposed"]
    if not proposed:
        raise ValueError(
            "Center of Gravity proposed no new site. In Use Existing Locations "
            "mode every answer is a facility the network already has."
        )

    models = MODEL_ON or [
        e["source_location_id"] for e in entries if e.get("result_type") == "fixed"
    ]
    if not models:
        raise ValueError(
            "No model facilities: set MODEL_ON to the ids of the existing "
            "facilities a new site should work like."
        )

    locations = scenario_data.get("locations", [])
    lanes = scenario_data.get("lanes", [])
    where = {
        loc["id"]: (float(loc["latitude"]), float(loc["longitude"]))
        for loc in locations
    }
    for model in models:
        if model not in where:
            raise ValueError(
                f"Model facility {model!r} is not a location in this scenario."
            )
    model_types = {loc.get("type") for loc in locations if loc["id"] in models}
    if len(model_types) > 1:
        raise ValueError(
            "The model facilities are of more than one type "
            f"({', '.join(sorted(map(str, model_types)))}), so the new site's type "
            "is ambiguous. Set MODEL_ON to facilities of one type."
        )
    (model_type,) = model_types

    def km(a, b):
        return haversine_km(*where[a], *where[b])

    # The model facilities' lanes in each direction, and their cost per km
    # for each transport mode.
    inbound = [
        ln for ln in lanes if ln["target"] in models and ln["source"] not in models
    ]
    outbound = [
        ln for ln in lanes if ln["source"] in models and ln["target"] not in models
    ]

    # A cost is per unit of quantity, so a rate is pooled only with rates of
    # the same transport mode in the same unit, and keeps that unit.
    def basis(lane):
        return (lane.get("transport_mode"), lane.get("quantity_unit"))

    rate = {}
    for direction, model_lanes in (("in", inbound), ("out", outbound)):
        per_km = {}
        for lane in model_lanes:
            distance = km(lane["source"], lane["target"])
            if lane.get("unit_cost") is not None and distance > 0:
                per_km.setdefault(basis(lane), []).append(
                    float(lane["unit_cost"]) / distance
                )
        if not per_km:
            raise ValueError(
                f"The model facilities have no priced {direction}bound lane to take "
                "a cost per km from. Supply one, or name other facilities in MODEL_ON."
            )
        rate[direction] = {b: statistics.median(r) for b, r in per_km.items()}

    for site in proposed:
        site_id = site["id"]
        locations.append(
            {
                "id": site_id,
                "latitude": site["latitude"],
                "longitude": site["longitude"],
                "type": model_type,
            }
        )
        where[site_id] = (float(site["latitude"]), float(site["longitude"]))
        added = set()
        for direction, model_lanes in (("in", inbound), ("out", outbound)):
            for lane in model_lanes:
                other = lane["source"] if direction == "in" else lane["target"]
                source, target = (
                    (other, site_id) if direction == "in" else (site_id, other)
                )
                mode, unit = basis(lane)
                # One lane per product, connection and mode, whatever its unit:
                # two such lanes are refused as conflicting.
                key = (source, target, lane["product"], mode)
                if key in added or (mode, unit) not in rate[direction]:
                    continue
                added.add(key)
                lanes.append(
                    {
                        "source": source,
                        "target": target,
                        "product": lane["product"],
                        "transport_mode": mode,
                        "quantity_unit": unit,
                        "unit_cost": rate[direction][(mode, unit)] * km(source, target),
                    }
                )

    scenario_data["locations"] = locations
    scenario_data["lanes"] = lanes
    return scenario_data

Before you report a saving

The script is a starting point. Read it against the network and adapt it where the network differs:

  • Lanes that skip the facilities. The script copies only lanes that touch a model facility. Direct supplier-to-customer lanes stay as they are, and none are added for the new site.
  • Different rates near the new site. The rate is a median over the model facilities' lanes. If the user has a tariff for the new region, use that.
  • No opening or running cost. The new site gets no fixed cost, no handling cost and no capacity limit, so the optimizer treats it as free and unlimited. A saving is only as good as these figures. Ask the user for them and add them: fixed_operating_cost in location_policy, unit_handling_cost with its quantity_unit in handling_policy, a capacity on the new lanes, in the unit the lane's quantity_unit states. Otherwise report the saving as transport only.

Messages this node can show

A run gives this node 5 minutes. A node that takes longer stops with Time limit reached.

Like every node, it can also stop on the checks before a run and the run's limits.