Examples

Hard Problems, Simple Models

Real planning problems with saturation curves, changeovers, choice models, simulations and uncertainty, each modeled in a page of Python and solved in seconds. Every result shown here is the actual output of the code next to it.

Run any example yourself, free: copy it into Google Colab or Jupyter, or download it as a notebook. The first line installs the free Seeker demo, which handles models with up to 50 variables and 20 constraints.

Public transport

Charging an electric bus fleet overnight

16 buses, one 900 kW grid connection, a monthly peak charge and evening prices. When should each bus charge, and how fast?

Why it is hard
Start times, charging speeds and a load curve that depends on every bus at once, billed on the night's peak. A MIP needs a binary per bus and 15-minute slot (768 of them) plus linking constraints.
How it looks in Seeker
Two search variables per bus: speed and delay. A short Python function turns them into charging sessions, upgrading the speed when a bus would not finish and clamping the start so every bus is full before it leaves. The night is costed by a plain simulation plugged in as a user-defined term.
31% lower monthly costEUR 41,862 down to EUR 28,715 per month, and the peak drops from 810 to 610 kW.

Modeling ideas used:
6. Simulate when algebra gets in the way2. Build feasibility into the model

a1_ebus_depot_charging.py
%pip install -q seekerdemo "setuptools<81"
# Application - Overnight charging of an electric bus fleet.
# 16 buses return to the depot between 19:00 and 23:00 and must be charged
# before their morning runs. The depot's grid connection is 900 kW; every kW of
# the night's peak costs a demand charge; evening electricity is expensive;
# and fast charging wears the batteries. When should each bus charge, and how
# fast? Today the depot plugs every bus in on arrival, at the slowest power
# that still gets it charged in time.
import random, seekerdemo as skr

random.seed(42)
B, SLOTS, H = 16, 48, 0.25                 # 15-min slots from 19:00 to 07:00
arrive = [random.randint(0, 16) for _ in range(B)]
leave  = [random.randint(36, 46) for _ in range(B)]
need   = [random.randint(150, 330) for _ in range(B)]          # kWh to recharge
POWER  = [50, 100, 150]                                          # kW options
price  = [0.34 if t < 12 else 0.22 if t < 20 else 0.14 for t in range(SLOTS)]
DEMAND_CHARGE, GRID_LIMIT, BASE_LOAD = 12.0, 900, 60     # EUR/kW/month, kW, kW
WEAR = {50: 0.000, 100: 0.010, 150: 0.035}                       # EUR per kWh

def plan(level, delay):
    """Turn search values into charging sessions; always finishes in time."""
    sessions = []
    for b in range(B):
        window = leave[b] - arrive[b]
        lv = max(level[b], next(i for i, p in enumerate(POWER)   # auto-upgrade if
                 if -(-need[b] // (p * H)) <= window))           # too slow to finish
        dur = int(-(-need[b] // (POWER[lv] * H)))
        start = arrive[b] + min(delay[b], window - dur)
        sessions.append((start, dur, POWER[lv]))
    return sessions

def night_cost(sessions):
    load = [BASE_LOAD] * SLOTS
    wear = 0.0
    for b, (s, d, p) in enumerate(sessions):
        for t in range(s, s + d):
            load[t] += p
        wear += WEAR[p] * need[b]
    energy = sum(l * H * c for l, c in zip(load, price))
    peak = max(load)
    over = max(0, peak - GRID_LIMIT)
    monthly = 30 * (energy + wear) + DEMAND_CHARGE * peak       # same plan every night
    return monthly + 1000 * over, peak, energy, wear            # over: never allowed

class Depot(skr.UserEvalTerm):
    def __init__(self):
        super().__init__()
    def evaluate(self, v):
        lv, dl = [round(x) for x in v[:B]], [round(x) for x in v[B:]]
        return night_cost(plan(lv, dl))[0]
    def incremental(self):
        return False

env = skr.Env('')
level = [env.categorical(0, 2) for _ in range(B)]                # how fast
delay = [env.ordinal(0, leave[b] - arrive[b]) for b in range(B)] # when to start
depot = Depot()
cost = env.user_defined_term(level + delay, depot)
env.minimize(cost, 5)

best = plan([round(x.get_value()) for x in level], [round(x.get_value()) for x in delay])
for name, sessions in [("plug in on arrival", plan([0] * B, [0] * B)),
                       ("Seeker schedule   ", best)]:
    monthly, peak, energy, wear = night_cost(sessions)
    print(f"{name}: peak {peak:3.0f} kW | per month: energy EUR {30 * energy:6,.0f},"
          f" demand charge EUR {DEMAND_CHARGE * peak:5,.0f}, battery wear EUR {30 * wear:4,.0f}"
          f" -> total EUR {monthly:6,.0f}")
env.end()
Output of this code on the free Seeker demo. Time-limited runs can differ slightly.
plug in on arrival: peak 810 kW | per month: energy EUR 32,142, demand charge EUR 9,720, battery wear EUR    0 -> total EUR 41,862
Seeker schedule   : peak 610 kW | per month: energy EUR 21,192, demand charge EUR 7,320, battery wear EUR  203 -> total EUR 28,715
Manufacturing

Sequencing a powder-coating line

18 orders in six colors. Dark to light needs a long booth cleaning, and late orders cost contract penalties. Planners sort by due date.

Why it is hard
Changeovers depend on which color comes before which, in one direction only, and every delay cascades into later orders.
How it looks in Seeker
One permutation is the only search variable. Cleaning times and finish times are computed from it. No constraints at all.
70% lower costCleaning falls from 424 to 182 minutes and late penalties from EUR 20,882 to EUR 6,058, compared with due-date order.

Modeling ideas used:
1. Search over what makes the problem easy4. Pick the variable that already knows the rules

a2_paint_shop_sequencing.py
%pip install -q seekerdemo "setuptools<81"
# Application - Sequencing a paint line with color changeovers and due dates.
# 18 orders wait for one powder-coating line. Switching color means cleaning
# the booth; going from dark to light takes much longer (and more solvent) than
# light to dark. Late orders cost money (contract penalties, idle assembly).
# Planners usually sort by due date. What order minimizes the total cost?
import random, seekerdemo as skr

random.seed(8)
N = 18
LIGHT = [95, 80, 60, 40, 20, 5]                 # lightness of the 6 colors
color = [random.randrange(6) for _ in range(N)]
minutes = [random.randint(25, 70) for _ in range(N)]
due = sorted(random.randint(60, 900) for _ in range(N))
penalty = [random.choice([2, 4, 8]) for _ in range(N)]   # EUR per late minute
def clean(a, b):                                 # booth cleaning, minutes
    return 0 if a == b else 12 + max(0, LIGHT[b] - LIGHT[a])
CLEAN_COST = 3.0                                  # EUR per cleaning minute

def model(env, seq):
    """seq[k] = order in position k  (search variable or fixed sequence)"""
    COL, DUR, DUE, PEN = (env.tensor(x) for x in (color, minutes, due, penalty))
    SETUP = env.tensor([[clean(a, b) for b in range(6)] for a in range(6)])
    t, cleaning, late = env.convert(0), [], []
    for k in range(N):
        if k > 0:
            c = env.index([env.index([seq[k-1]], COL), env.index([seq[k]], COL)], SETUP)
            cleaning.append(c)
            t = t + c
        t = t + env.index([seq[k]], DUR)                 # finish time, derived
        late.append(env.index([seq[k]], PEN) * env.max_0(t - env.index([seq[k]], DUE)))
    clean_min, late_cost = env.sum(cleaning), env.sum(late)
    return clean_min, late_cost, CLEAN_COST * clean_min + late_cost

# Baseline: earliest due date first (orders are already sorted by due date)
env = skr.Env('')
c, l, total = model(env, env.convert(list(range(N))))
env.evaluate()
print(f"due-date order : cleaning {c.get_value():4.0f} min | late penalties EUR {l.get_value():5.0f}"
      f" | total EUR {total.get_value():5.0f}")
env.end()

env = skr.Env('')
seq = env.permutation(N).get_permutation()
c, l, total = model(env, seq)
env.minimize(total, 5)
print(f"Seeker sequence: cleaning {c.get_value():4.0f} min | late penalties EUR {l.get_value():5.0f}"
      f" | total EUR {total.get_value():5.0f}")
print("colors in order:", " ".join("ABCDEF"[color[int(s.get_value())]] for s in seq))
env.end()
Output of this code on the free Seeker demo. Time-limited runs can differ slightly.
due-date order : cleaning  424 min | late penalties EUR 20882 | total EUR 22154
Seeker sequence: cleaning  182 min | late penalties EUR  6058 | total EUR  6604
colors in order: A C B B B B B B F F E D D D D D A A
Software

Pricing three subscription tiers

Three customer segments choose Basic, Pro or Business, monthly or annual, or walk away. Prices must end in 9, step up between tiers, and the annual discount must stay between 10% and 30%.

Why it is hard
Demand is a nonlinear, non-convex function of all four prices at once (a segmented logit model), and the business rules tie the prices together.
How it looks in Seeker
The price ladder is built from increments, so every rule holds by construction. The logit model is written exactly as it appears on paper. Four search variables.
64% more monthly profitEUR 199,346 to EUR 327,695 with 5,013 instead of 7,563 customers. A full check of all 1.1 million price combinations finds the same optimum.

Modeling ideas used:
2. Build feasibility into the model3. Don't binarize

a3_saas_tier_pricing.py
%pip install -q seekerdemo "setuptools<81"
# Application - Pricing three subscription tiers with monthly and annual billing.
# Customers from three segments pick Basic, Pro or Business, billed monthly or
# annually, or walk away (a segmented logit choice model, e.g. estimated from
# a conjoint study). Rules: prices end in 9, each tier costs clearly more than
# the one below, and the annual discount is between 10% and 30%.
import seekerdemo as skr

#            size  price sens.  value of Basic, Pro, Business   dislikes annual
segments = [(5000, 0.080,       [3.0, 3.6, 3.4],                 0.6),   # freelancers
            (2500, 0.035,       [3.0, 4.6, 5.0],                 0.3),   # small teams
            ( 600, 0.012,       [2.5, 4.4, 6.2],                 0.1)]   # companies
SERVE = [4, 9, 22]                   # monthly cost to serve one customer per tier

def tier_prices(env, k_basic, k_pro, k_biz):
    basic = 10 * k_basic - 1                         # ends in 9 by construction
    pro = basic + 10 * k_pro                         # always more than Basic
    biz = pro + 10 * k_biz                           # always more than Pro
    return [basic, pro, biz]

def monthly_profit(env, prices, discount):
    total, customers = 0, 0
    for size, beta, value, annual_pain in segments:
        u_month = [env.exp(v - beta * p) for v, p in zip(value, prices)]
        u_year = [env.exp(v - annual_pain - beta * p * (1 - discount))
                  for v, p in zip(value, prices)]
        denom = 1 + env.sum(u_month) + env.sum(u_year)          # 1 = walk away
        customers = customers + size * (denom - 1) / denom
        for j in range(3):
            margin_m = prices[j] - SERVE[j]
            margin_y = prices[j] * (1 - discount) - SERVE[j]
            total = total + size * (u_month[j] * margin_m + u_year[j] * margin_y) / denom
    return total, customers

# Today's price list: 19 / 49 / 99, annual billing 17% off
env = skr.Env('')
today, cust = monthly_profit(env, [env.convert(p) for p in (19, 49, 99)], env.convert(0.17))
env.evaluate()
print(f"today  19 /  49 /  99, annual -17%: {cust.get_value():5,.0f} customers,"
      f" profit EUR {today.get_value():8,.0f} per month")
env.end()

env = skr.Env('')
k = [env.ordinal(1, 30), env.ordinal(1, 30), env.ordinal(1, 60)]
discount = env.continuous(0.10, 0.30)
prices = tier_prices(env, *k)
profit, cust = monthly_profit(env, prices, discount)
env.maximize(profit, 5)
p = [round(x.get_value()) for x in prices]
print(f"Seeker {p[0]:3} / {p[1]:3} / {p[2]:3}, annual -{100*discount.get_value():.0f}%:"
      f" {cust.get_value():5,.0f} customers, profit EUR {profit.get_value():8,.0f} per month")
env.end()
Output of this code on the free Seeker demo. Time-limited runs can differ slightly.
today  19 /  49 /  99, annual -17%: 7,563 customers, profit EUR  199,346 per month
Seeker  49 / 109 / 249, annual -10%: 5,013 customers, profit EUR  327,695 per month
Marketing

Splitting a EUR 4M marketing budget

Eight channels that saturate, TV and podcasts with production costs before they reach anyone, TV lifting paid search, and the option to keep money unspent.

Why it is hard
Diminishing returns, threshold effects and channel interactions in one objective.
How it looks in Seeker
A split total over nine shares (eight channels plus 'keep it') respects the budget exactly. Response curves are written directly.
13% more profit, same budgetEUR 2.95M to EUR 3.33M against last year's split. A 40-second run finds the same allocation.

Modeling ideas used:
4. Pick the variable that already knows the rules3. Don't binarize

a4_marketing_budget_mix.py
%pip install -q seekerdemo "setuptools<81"
# Application - Where should next year's marketing budget go?
# Up to EUR 4M, in blocks of EUR 100k, across 8 channels. Every channel
# saturates (the 10th block buys less than the 1st), TV and podcasts carry a
# fixed production cost before they reach anyone, and TV lifts the response of
# paid search. Any money not spent is kept. Last year's split is the baseline.
import math, seekerdemo as skr

channels = ["TV", "Paid search", "Social", "Display", "Podcasts", "Trade fairs",
            "Email", "Partner promos"]
peak  = [9.0, 5.5, 4.0, 2.2, 2.6, 3.0, 1.4, 2.4]   # max extra sales, EUR M
scale = [12, 6, 7, 5, 6, 9, 2, 6]                   # blocks to reach 63% of peak
fixed = [6, 0, 0, 0, 2, 0, 0, 0]                    # blocks before any effect
MARGIN, BLOCKS = 0.45, 40                           # gross margin, 40 x 100k
last_year = [14, 10, 6, 4, 0, 4, 1, 1]              # 40 blocks, all spent

def profit(env, spend):                              # spend: list of 8 terms
    sales = []
    for c in range(8):
        effective = env.max_0(spend[c] - fixed[c])
        sales.append(peak[c] * (1 - env.exp(-1 * effective / scale[c])))
    tv_on = sales[0] / peak[0]                        # 0..1 share of TV's effect
    sales[1] = sales[1] * (1 + 0.5 * tv_on)          # TV halo on search
    return MARGIN * env.sum(sales) - 0.1 * env.sum(spend), env.sum(sales)

env = skr.Env('')
base, base_sales = profit(env, env.convert(last_year))
env.evaluate()
print(f"last year's split: spend EUR 4.0M, extra sales EUR {base_sales.get_value():4.1f}M,"
      f" profit EUR {base.get_value():4.2f}M")
env.end()

env = skr.Env('')
split = env.split_total(9, BLOCKS).get_combination()   # 8 channels + "keep it"
gain, sales = profit(env, split[:8])
env.maximize(gain, 5)
blocks = [round(x.get_value()) for x in split]
print(f"Seeker split     : spend EUR {sum(blocks[:8]) / 10:.1f}M, extra sales EUR "
      f"{sales.get_value():4.1f}M, profit EUR {gain.get_value():4.2f}M")
for name, b, l in zip(channels, blocks, last_year):
    print(f"   {name:15} EUR {b / 10:3.1f}M   (last year {l / 10:3.1f}M)")
print(f"   {'kept':15} EUR {blocks[8] / 10:3.1f}M")
env.end()
Output of this code on the free Seeker demo. Time-limited runs can differ slightly.
last year's split: spend EUR 4.0M, extra sales EUR 15.4M, profit EUR 2.95M
Seeker split     : spend EUR 4.0M, extra sales EUR 16.3M, profit EUR 3.33M
   TV              EUR 2.0M   (last year 1.4M)
   Paid search     EUR 0.8M   (last year 1.0M)
   Social          EUR 0.5M   (last year 0.6M)
   Display         EUR 0.2M   (last year 0.4M)
   Podcasts        EUR 0.0M   (last year 0.0M)
   Trade fairs     EUR 0.1M   (last year 0.4M)
   Email           EUR 0.2M   (last year 0.1M)
   Partner promos  EUR 0.2M   (last year 0.1M)
   kept            EUR 0.0M
Energy

Planning a factory battery against uncertain solar

Tomorrow's rooftop output is uncertain. The battery's 24 hourly moves are planned against 40 look-alike past days, minimizing expected cost plus the cost of the worst days.

Why it is hard
Uncertain, hour-to-hour correlated solar, a battery that must stay within its limits, time-of-use prices and a risk measure in the objective.
How it looks in Seeker
An analog-ensemble forecast feeds whole-day scenarios straight into Seeker's stochastic solver. The battery clips automatically at empty and full, so every plan is valid.
EUR 44,500 a year better than the fixed ruleEUR 1,858 expected cost per day versus EUR 1,980 for the rule and EUR 2,143 with the battery idle. The worst 10% of days get cheaper too.

Modeling ideas used:
7. Hand the optimizer your model's uncertainty2. Build feasibility into the model

a5_solar_battery_dayahead.py
%pip install -q seekerdemo "setuptools<81" numpy
# Application - Day-ahead battery plan for a factory with rooftop solar.
# Tomorrow's solar output is uncertain. An "analog ensemble" (a simple, widely
# used ML forecast) takes the 40 past days whose weather forecast looked most
# like tomorrow's and uses their real 24-hour solar profiles as scenarios.
# Seeker plans the battery's 24 hourly moves against all scenarios at once,
# minimizing expected cost plus the cost of the worst 10% of days.
import numpy as np, seekerdemo as skr

rng = np.random.default_rng(1)
hours = np.arange(24)
clear_sky = np.clip(np.sin((hours - 6) / 13 * np.pi), 0, None) * 900      # kW
cloud_fc = rng.uniform(0, 1, 730)                         # 2 years of forecasts
cloud_real = np.clip(cloud_fc + rng.normal(0, .2, (24, 730)), 0, 1).T
history = clear_sky * (1 - .8 * cloud_real ** 1.5)        # what the roof produced

tomorrow_fc = 0.45
analogs = history[np.argsort(abs(cloud_fc - tomorrow_fc))[:40]]   # the ML step

class Solar(skr.UserVectorDistribution):
    def __init__(self):
        super().__init__()
    def sample_n(self, n):                                # whole-day profiles,
        pick = np.random.default_rng(7).integers(0, 40, n) # same draws every run
        return analogs[pick].tolist()

load = 500 + 150 * ((hours >= 7) & (hours <= 18))         # factory kW
buy = np.where((hours >= 17) & (hours <= 21), 0.38, np.where(hours >= 7, 0.27, 0.16))
SELL, CAP, POWER = 0.07, 1200, 400                       # EUR/kWh, kWh, kW

def plan(flows_fixed=None):
    env = skr.Env('', True)
    dist = Solar()                                        # keep a reference
    solar = env.user_vector_distribution(dist, 200)
    flows = flows_fixed or [env.continuous(-POWER, POWER) for _ in hours]
    soc, cost = env.convert(CAP / 2), 0
    for h in hours:
        new = env.min([env.convert(CAP), env.max_0(soc + flows[h])])  # auto-clip
        actual = new - soc                                # what really flowed
        soc = new
        net = load[h] - solar[h] + actual                 # grid draw (random)
        cost = cost + buy[h] * env.max_0(net) - SELL * env.max_0(-1 * net)
    cost = cost + 0.2 * (CAP / 2 - soc)                   # value of leftover charge
    exp_cost = env.aggregate_mean(cost)
    worst10 = env.aggregate_cvar(cost, 0.1, True)
    target = exp_cost + 0.5 * worst10
    if flows_fixed: env.evaluate()
    else: env.minimize(target, 6)
    res = exp_cost.get_value(), worst10.get_value()
    env.end()
    return res

idle = plan([0] * 24)
rule = plan([POWER if 10 <= h <= 12 else -POWER if 17 <= h <= 19 else 0 for h in hours])
best = plan()
for name, (e, w) in [("battery idle                ", idle),
                     ("rule: charge 10-13, use 17-20", rule),
                     ("Seeker plan                 ", best)]:
    print(f"{name}: expected cost EUR {e:6.0f} | worst 10% of days EUR {w:6.0f}")
Output of this code on the free Seeker demo. Time-limited runs can differ slightly.
battery idle                : expected cost EUR   2143 | worst 10% of days EUR   2361
rule: charge 10-13, use 17-20: expected cost EUR   1980 | worst 10% of days EUR   2193
Seeker plan                 : expected cost EUR   1858 | worst 10% of days EUR   2093