Modelling Examples#

Conventions#

  • Hermax solves minimization problems.

  • model.obj[w] += lit adds a soft unit clause [lit] of weight w.

  • A soft unit clause [lit] pays cost w when lit is false.

  • To penalize a Boolean decision x when it is true, use model.obj[w] += ~x.

Example 01: Toy Soft Clauses#

Introduce the basic MaxSAT workflow with hard clauses and weighted soft unit clauses.

New primitives#

  • hermax.model.Model

  • hermax.model.Model.bool()

  • Hard constraints with model &=

  • Soft constraints with model.obj[w] +=

  • hermax.model.Model.solve()

Model#

This is the smallest useful weighted partial MaxSAT instance: a hard CNF core plus a weighted set of soft preferences. It exposes Hermax’s literal/soft-clause polarity convention without introducing PB encoders or finite-domain variables.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & a,b,c \in \{0,1\} \\ \text{Hard constraints:}\quad & (a \lor b)\ \land\ (\neg a \lor c) \\ \text{Soft clauses:}\quad & (a, 3),\ (\neg b, 2),\ (c, 1) \\ \text{Objective:}\quad & \min \sum_i w_i \cdot [\text{soft clause } i \text{ is violated}] \end{aligned}\end{split}\]

Code#

examples/model/01_toy_soft_clauses.py#
from hermax.model import Model


m = Model()

a = m.bool("a")
b = m.bool("b")
c = m.bool("c")

m &= (a | b)  # a OR b: at least one must be true
m &= (~a | c)  # (NOT a) OR c: if a then c

m.obj[3] += a    # soft clause [a]: pay 3 if a is false
m.obj[2] += ~b   # soft clause [~b]: pay 2 if b is true
m.obj[1] += c    # soft clause [c]: pay 1 if c is false

r = m.solve()

print("status:", r.status)
print("cost:", r.cost)
print("a,b,c:", r[a], r[b], r[c])

Output#

This output shows one optimal assignment and the resulting weighted MaxSAT cost.

$ python examples/model/01_toy_soft_clauses.py
status: optimum
cost: 0
a,b,c: True False True

Example 02: Knapsack#

Show a weighted pseudo-Boolean capacity constraint and a MaxSAT objective that maximizes profit by minimizing penalties for not selecting items.

New primitives#

  • hermax.model.BoolVector

  • PB expressions over booleans (sum(w_i * x_i) <= C)

Model#

This is the first pseudo-boolean: one weighted capacity inequality and a linear profit objective. Knapsack is one of Karp’s original NP-complete problems [1], and it is also a standard optimization reference problem in its own right [2].

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x_i \in \{0,1\}\ \text{(item selected)} \\ \text{Capacity:}\quad & \sum_i \mathrm{weight}_i x_i \le C \\ \text{Profit objective:}\quad & \max \sum_i \mathrm{profit}_i x_i \\ \text{MaxSAT form:}\quad & \min \sum_i \mathrm{profit}_i (1 - x_i) \end{aligned}\end{split}\]

The code uses soft unit clauses [x_i] so the cost is paid when x_i=0, which is exactly the transformed minimization objective.

Problem#

_images/knapsack_problem.svg

Code#

examples/model/02_knapsack_pb.py#
from hermax.model import Model


m = Model()

# Items: (weight, profit)
items = [(2, 6), (3, 8), (4, 9), (5, 10)]
take = m.bool_vector("take", len(items))

capacity = 8

m &= sum(w * take[i] for i, (w, _) in enumerate(items)) <= capacity

for i, (_, profit) in enumerate(items):
    m.obj[profit] += take[i]

r = m.solve()

chosen = [i for i in range(len(items)) if r[take[i]]]
total_weight = sum(items[i][0] for i in chosen)
total_profit = sum(items[i][1] for i in chosen)

print("status:", r.status)
print("cost:", r.cost)
print("chosen_items:", chosen)
print("total_weight:", total_weight, "capacity:", capacity)
print("total_profit:", total_profit)

Output#

The solver selects a feasible subset under the capacity constraint and reports the chosen items.

$ python examples/model/02_knapsack_pb.py
status: optimum
cost: 15
chosen_items: [1, 3]
total_weight: 8 capacity: 8
total_profit: 18

Solution#

_images/knapsack_solution.svg

Example 03: Minimum Set Cover#

Model the classic minimum set cover problem using booleans, disjunctions, and weighted soft penalties on selected sets.

New primitives#

  • hermax.model.BoolDict (keyed booleans)

  • hermax.model.Model.vector() with hermax.model.BoolVector.at_least_one()

Model#

This is a weighted set-cover formulation. It is an example of disjunctions over a dictionary of booleans and an objective tied to selected decisions. Set cover is one of Karp’s original NP-complete problems [1] and is also a common problem in Garey and Johnson [3].

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x_S \in \{0,1\}\ \text{for each candidate set } S \\ \text{Coverage constraints:}\quad & \bigvee_{S:\ u \in S} x_S \qquad \forall u \in U \\ \text{Objective:}\quad & \min \sum_S c_S x_S \end{aligned}\end{split}\]

The code encodes the objective with soft unit clauses [~x_S], so a cost is paid when x_S is true.

Problem#

_images/set_cover_problem.svg

Code#

examples/model/03_set_cover.py#
from hermax.model import Model


m = Model()

universe = ["u1", "u2", "u3", "u4"]
sets = {
    "S1": {"u1", "u2"},
    "S2": {"u2", "u3"},
    "S3": {"u3", "u4"},
    "S4": {"u1", "u4"},
}
set_cost = {"S1": 3, "S2": 2, "S3": 3, "S4": 2}

pick = m.bool_dict("pick", list(sets.keys()))

for u in universe:
    cover_u = [pick[s] for s in sets if u in sets[s]]
    m &= m.vector(cover_u, name=f"cover_{u}").at_least_one()

for s in sets:
    m.obj[set_cost[s]] += ~pick[s]

r = m.solve()

chosen_sets = [s for s in sets if r[pick[s]]]
print("status:", r.status)
print("cost:", r.cost)
print("chosen_sets:", chosen_sets)

Output#

The output shows a minimum-cost cover and the selected sets.

$ python examples/model/03_set_cover.py
status: optimum
cost: 4
chosen_sets: ['S2', 'S4']

Solution#

_images/set_cover_solution.svg

Example 04: Minimum Vertex Cover#

Model weighted vertex cover on a small graph.

Model#

Vertex cover is the graph analogue of set cover. One clause per edge. It is one of Karp’s original NP-complete problems [1] and one of the common graph problems in Garey and Johnson [3].

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x_v \in \{0,1\}\ \text{(vertex } v \text{ is selected)} \\ \text{Edge coverage:}\quad & x_u \lor x_v \qquad \forall (u,v)\in E \\ \text{Objective:}\quad & \min \sum_{v \in V} c_v x_v \end{aligned}\end{split}\]

As with the previous example, the code uses soft unit clauses [~x_v] so selecting a vertex incurs its cost.

Problem#

_images/vertex_cover_problem.svg

Code#

examples/model/04_vertex_cover.py#
from hermax.model import Model


m = Model()

vertices = [0, 1, 2, 3, 4]
edges = [(0, 1), (1, 2), (2, 3), (3, 4), (0, 4)]
cost = {0: 2, 1: 1, 2: 2, 3: 1, 4: 2}

cover = m.bool_dict("cover", vertices)

for u, v in edges:
    m &= (cover[u] | cover[v])

for v in vertices:
    m.obj[cost[v]] += ~cover[v]

r = m.solve()

chosen = [v for v in vertices if r[cover[v]]]
print("status:", r.status)
print("cost:", r.cost)
print("vertex_cover:", chosen)

Output#

The solver returns one minimum-cost vertex cover for the small graph instance.

$ python examples/model/04_vertex_cover.py
status: optimum
cost: 4
vertex_cover: [0, 1, 3]

Solution#

_images/vertex_cover_solution.svg

Example 05: Job Assignment#

Assign one worker to each task, enforce worker capacity, and minimize assignment cost.

New primitives#

  • hermax.model.EnumVar / hermax.model.EnumDict

  • Enum equality literals (assign[t] == worker)

  • hermax.model.BoolVector.at_most_one()

Model#

A single typed variable per task replaces a full Boolean assignment matrix while still exposing exact equality literals for capacity and cost constraints. Unlike the NP-hard examples above, the classical assignment problem is polynomial-time and is famously solved by the Hungarian method [4].

\[\begin{split}\begin{aligned} \text{Variables:}\quad & a_t \in \{\text{alice},\text{bob},\text{carol}\} \\ \text{Worker capacity:}\quad & \sum_t [a_t = w] \le 1 \qquad \forall w \\ \text{Objective:}\quad & \min \sum_t \sum_w c_{t,w}\,[a_t = w] \end{aligned}\end{split}\]

The bracket term [a_t = w] is realized in code by the literal (assign[t] == w) and softened as [~(assign[t] == w)].

Problem#

_images/job_assignment_problem.svg

Code#

examples/model/05_job_assignment.py#
from hermax.model import Model


m = Model()

workers = ["alice", "bob", "carol"]
tasks = ["t1", "t2", "t3"]
cost = {
    ("t1", "alice"): 5,
    ("t1", "bob"): 1,
    ("t1", "carol"): 3,
    ("t2", "alice"): 2,
    ("t2", "bob"): 6,
    ("t2", "carol"): 1,
    ("t3", "alice"): 4,
    ("t3", "bob"): 3,
    ("t3", "carol"): 2,
}

assign = m.enum_dict("assign", tasks, choices=workers, nullable=False)

for w in workers:
    chosen_by_worker = [(assign[t] == w) for t in tasks]
    m &= m.vector(chosen_by_worker, name=f"worker_{w}_tasks").at_most_one()

for t in tasks:
    for w in workers:
        m.obj[cost[(t, w)]] += ~(assign[t] == w)

r = m.solve()

print("status:", r.status)
print("cost:", r.cost)
print("assignment:", {t: r[assign[t]] for t in tasks})

Output#

This output shows one feasible assignment that respects worker capacity and the resulting total cost.

$ python examples/model/05_job_assignment.py
status: optimum
cost: 5
assignment: {'t1': 'bob', 't2': 'alice', 't3': 'carol'}

Solution#

_images/job_assignment_solution.svg

Example 06: Enum Subset Disjunction#

Demonstrate the fast categorical subset helper hermax.model.EnumVar.is_in().

New primitives#

  • hermax.model.EnumVar.is_in()

Model#

The model isolates the categorical subset helper with a small optimization target. This pattern appears frequently in scheduling models.

\[\begin{split}\begin{aligned} \text{Variable:}\quad & s \in \{\text{morning},\text{day},\text{night},\text{graveyard}\} \\ \text{Subset constraint:}\quad & s \in \{\text{morning},\text{day}\} \\ \text{CNF form:}\quad & [s=\text{morning}] \lor [s=\text{day}] \end{aligned}\end{split}\]

Code#

examples/model/06_enum_subset_shift.py#
from hermax.model import Model


m = Model()

shift = m.enum("shift", choices=["morning", "day", "night", "graveyard"], nullable=False)

m &= shift.is_in(["morning", "day"])
m.obj[4] += ~(shift == "morning")
m.obj[1] += ~(shift == "day")

r = m.solve()

print("status:", r.status)
print("cost:", r.cost)
print("shift:", r[shift])

Output#

The chosen shift is guaranteed to belong to the allowed subset.

$ python examples/model/06_enum_subset_shift.py
status: optimum
cost: 1
shift: day

Example 07: Allowed Configurations#

Model an extensional table constraint over a temporary typed vector view.

New primitives#

  • hermax.model.Model.vector() typed view

  • hermax.model.IntVector.is_in()

Model#

This is an extensional constraint (table of valid tuples), a core CP modelling primitive. The example uses a temporary typed vector view so the syntax stays close to the mathematical statement.

\[\begin{split}\begin{aligned} \text{Decision vector:}\quad & \mathbf{x}=(cpu,ram,mobo) \\ \text{Allowed table:}\quad & T=\{T_1,\dots,T_m\} \\ \text{Constraint:}\quad & \mathbf{x} \in T \\ \text{Encoding idea:}\quad & \exists s_1,\dots,s_m:\ \mathrm{ExactlyOne}(s_1,\dots,s_m) \\ & \qquad\land \bigwedge_{i=1}^m \left(s_i \rightarrow (\mathbf{x}=T_i)\right) \end{aligned}\end{split}\]

The current implementation uses row-selector literals, deduplicates repeated rows, and gates each row with exact scalar equalities.

Code#

examples/model/07_table_allowed_configs.py#
from hermax.model import Model


m = Model()

cpu = m.int("cpu", lb=0, ub=6)
ram = m.int("ram", lb=0, ub=9)
mobo = m.int("mobo", lb=0, ub=6)

valid_configs = [
    (1, 2, 1),
    (2, 4, 2),
    (3, 4, 3),
    (4, 8, 5),
]

spec = m.vector([cpu, ram, mobo], name="system_spec")
m &= spec.is_in(valid_configs)

m &= (cpu == 2)
m &= (ram == 4)

r = m.solve()

print("status:", r.status)
print("system_spec:", r[spec])
print("cpu/ram/mobo:", r[cpu], r[ram], r[mobo])

Output#

The selected configuration is one of the allowed table rows.

$ python examples/model/07_table_allowed_configs.py
status: sat
system_spec: [2, 4, 2]
cpu/ram/mobo: 2 4 2

Example 08: Element Constraint (@)#

Show the CP element constraint using lazy array indexing: array @ int_var.

New primitives#

  • array @ int_var lazy multiplexer descriptor

Model#

This is the element-constraint pattern from CP: select a constant from an array using an integer variable, then constrain the selected value. The lazy @ descriptor avoids introducing a separate “selected-cost” integer variable.

\[\begin{split}\begin{aligned} \text{Given:}\quad & costs = [c_0,\dots,c_{n-1}] \\ \text{Variables:}\quad & w \in \{0,\dots,n-1\},\ budget \\ \text{Constraint:}\quad & costs[w] \le budget \\ \text{Compilation idea:}\quad & \bigwedge_{k=0}^{n-1} \left((w=k) \rightarrow (c_k \le budget)\right) \end{aligned}\end{split}\]

For constant right-hand sides, the implementation simplifies this further into domain filtering (forbidding impossible index values).

Problem#

_images/element_constraint_problem.svg

Code#

examples/model/08_multiplexer_budget.py#
from hermax.model import Model


m = Model()

worker = m.int("worker", lb=0, ub=3)
budget = m.int("budget", lb=0, ub=120)
worker_costs = [20, 40, 75, 120]

m &= (30 <= worker_costs @ worker)  # selected worker cost must be at least 30
m &= (worker_costs @ worker <= budget)  # selected worker cost must fit budget
m &= (budget <= 80)
m.obj += budget

r = m.solve()

print("status:", r.status)
print("worker:", r[worker])
print("budget:", r[budget])
print("selected_cost:", worker_costs[r[worker]])

Output#

This output shows the chosen index and the selected cost value constrained by the budget.

$ python examples/model/08_multiplexer_budget.py
status: optimum
worker: 1
budget: 40
selected_cost: 40

Solution#

_images/element_constraint_solution.svg

Example 09: Sudoku (9x9)#

Demonstrate matrix modelling, NumPy-like slicing, and all_different on rows, columns, and 3x3 subgrids.

New primitives#

  • hermax.model.EnumMatrix

  • NumPy-like indexing (grid[r, :], grid[:, c], grid[a:b, c:d])

  • flatten() on matrix views

  • hermax.model.EnumVector.all_different()

Model#

Matrix-focused example. The instance uses a fixed puzzle, so rows, columns, and 3x3 boxes are easy to inspect with the same typed-vector operations.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x_{r,c} \in \{\texttt{"1"},\dots,\texttt{"9"}\} \\ \text{Row constraints:}\quad & \mathrm{AllDifferent}(x_{r,1},\dots,x_{r,9}) \qquad \forall r \\ \text{Column constraints:}\quad & \mathrm{AllDifferent}(x_{1,c},\dots,x_{9,c}) \qquad \forall c \\ \text{Block constraints:}\quad & \mathrm{AllDifferent}(\text{cells in each } 3\times3 \text{ block}) \\ \text{Clues:}\quad & \text{9x9 puzzle instance} \end{aligned}\end{split}\]

Warning

Sudoku “digits” are better modeled as enums than as ints. The puzzle is really about exact symbols, not about arithmetic values. If sudoku had letters instead of digits, it would be played the same way, so the numeric nature of the symbols is not relevant to the combinatorial search. Using enums is more efficient when possible.

Problem#

_images/sudoku_problem.svg

Code#

examples/model/09_sudoku9_single_clue.py#
from hermax.model import Model


m = Model()
choices = [str(i) for i in range(1, 10)]
grid = m.enum_matrix("cell", rows=9, cols=9, choices=choices, nullable=False)

for r in range(9):
    m &= grid[r, :].all_different()
for c in range(9):
    m &= grid[:, c].all_different()
for br in range(0, 9, 3):
    for bc in range(0, 9, 3):
        m &= grid[br : br + 3, bc : bc + 3].flatten().all_different()

# Fixed Sudoku instance
givens = [
    (0, 0, "5"), (0, 1, "3"), (0, 4, "7"),
    (1, 0, "6"), (1, 3, "1"), (1, 4, "9"), (1, 5, "5"),
    (2, 1, "9"), (2, 2, "8"), (2, 7, "6"),
    (3, 0, "8"), (3, 4, "6"), (3, 8, "3"),
    (4, 0, "4"), (4, 3, "8"), (4, 5, "3"), (4, 8, "1"),
    (5, 0, "7"), (5, 4, "2"), (5, 8, "6"),
    (6, 1, "6"), (6, 6, "2"), (6, 7, "8"),
    (7, 3, "4"), (7, 4, "1"), (7, 5, "9"), (7, 8, "5"),
    (8, 4, "8"), (8, 7, "7"), (8, 8, "9"),
]

for r, c, v in givens:
    m &= (grid[r, c] == v)

r = m.solve()

print("status:", r.status)
for i in range(9):
    print(f"row_{i + 1}:", r[grid[i, :]])

Output#

The output shows the deterministic completion of the fixed puzzle instance.

$ python examples/model/09_sudoku9_single_clue.py
status: sat
row_1: ['5', '3', '4', '6', '7', '8', '9', '1', '2']
row_2: ['6', '7', '2', '1', '9', '5', '3', '4', '8']
row_3: ['1', '9', '8', '3', '4', '2', '5', '6', '7']
row_4: ['8', '5', '9', '7', '6', '1', '4', '2', '3']
row_5: ['4', '2', '6', '8', '5', '3', '7', '9', '1']
row_6: ['7', '1', '3', '9', '2', '4', '8', '5', '6']
row_7: ['9', '6', '1', '5', '3', '7', '2', '8', '4']
row_8: ['2', '8', '7', '4', '1', '9', '6', '3', '5']
row_9: ['3', '4', '5', '2', '8', '6', '1', '7', '9']

Solution#

_images/sudoku_solution.svg

Example 10: No Overlap Scheduling#

Show scheduling constraints with object interval methods.

New primitives#

  • hermax.model.IntervalVar

  • hermax.model.Model.interval()

  • hermax.model.IntervalVar.ends_before(), hermax.model.IntervalVar.no_overlap()

Model#

This model uses a scheduling oriented API layer built on top of ladder integers. IntervalVar keeps the model readable while compiling to plain SAT/MaxSAT constraints.

\[\begin{split}\begin{aligned} \text{For each task } i:\quad & s_i = \text{start},\ e_i = \text{end},\ d_i=\text{fixed duration} \\ \text{Interval identity:}\quad & e_i = s_i + d_i \\ \text{Non-overlap:}\quad & e_i \le s_j \ \lor\ e_j \le s_i \\ \text{Preference (example):}\quad & \min s_C \end{aligned}\end{split}\]

Internally, the interval identity is compiled with a direct ladder-bit weld (linear number of binary clauses, zero auxiliary variables), not with a generic PB/Card equality encoder.

Code#

examples/model/10_interval_scheduling.py#
from hermax.model import Model


m = Model()

task_a = m.interval("A", start=0, duration=5, end=24)
task_b = m.interval("B", start=0, duration=3, end=24)
task_c = m.interval("C", start=0, duration=4, end=24)

m &= task_a.no_overlap(task_b)
m &= task_b.no_overlap(task_c)
m &= task_a.no_overlap(task_c)
m &= task_a.ends_before(task_c)

m.obj[1] += task_c.start

r = m.solve()

print("status:", r.status)
print("cost:", r.cost)
print("A:", r[task_a])
print("B:", r[task_b])
print("C:", r[task_c])

Output#

The printed intervals confirm the no-overlap and precedence constraints.

$ python examples/model/10_interval_scheduling.py
status: optimum
cost: 5
A: {'start': 0, 'end': 5, 'duration': 5}
B: {'start': 9, 'end': 12, 'duration': 3}
C: {'start': 5, 'end': 9, 'duration': 4}

Example 11: Int Variables in the Objective#

Demonstrate obj[w] += int_var (ladder-bit objective lowering) combined with hard PB constraints.

New primitives#

  • model.obj[w] += int_var with hermax.model.IntVar

Model#

This model shows typed finite-domain variables in the MaxSAT objective without introducing a separate arithmetic backend API.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x,y \in \mathbb{Z}\ \text{(bounded IntVar)},\ b \in \{0,1\} \\ \text{Hard constraints:}\quad & x+y \ge 8,\quad x+2y \le 14,\quad x+b \le 6 \\ \text{Objective:}\quad & \min (3x + y + 2b) \end{aligned}\end{split}\]

model.obj[w] += x is lowered to soft clauses over the ladder threshold bits of x (linear in the ladder width), plus a constant offset contribution from the lower bound.

Code#

examples/model/11_int_objective.py#
from hermax.model import Model


m = Model()

x = m.int("x", lb=0, ub=10)
y = m.int("y", lb=0, ub=10)
use_bonus = m.bool("use_bonus")

m &= (x + y >= 8)
m &= (x + 2 * y <= 14)
m &= (x + use_bonus <= 6)

m.obj[3] += x
m.obj[1] += y
m.obj[2] += ~use_bonus

r = m.solve()

print("status:", r.status)
print("cost:", r.cost)
print("x,y,use_bonus:", r[x], r[y], r[use_bonus])

Output#

This output shows a solution minimizing an objective that includes integer variables and a boolean penalty.

$ python examples/model/11_int_objective.py
status: optimum
cost: 12
x,y,use_bonus: 2 6 False

Example 12: WiFi Channel Assignment#

Show a compact domain model using nullable enum states for router channels, hard interference constraints, and soft penalties. Inspired on [5] and [6], this is a simplified version of the WiFi channel assignment.

New primitives#

  • hermax.model.EnumDict with nullable=True

  • Practical composition of enum equality literals and soft penalties

Model#

This model combines nullable enums, graph constraints, and soft penalties. It is a good example after the smaller, isolated primitives explored earlier on this page.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & state_r \in \{f_1,f_2,f_3\}\cup\{\text{offline}\} \qquad \forall r \\ \text{Interference hard constraints:}\quad & \neg(state_u=f)\ \lor\ \neg(state_v=f) \qquad \forall (u,v)\in E,\ \forall f \\ \text{Soft penalties:}\quad & \text{offline penalties} + \text{per-frequency usage penalties} \end{aligned}\end{split}\]

The model uses nullable enums so “offline” is represented as the absence of a selected frequency (decoded as None), rather than as an extra binary-variable layer.

Problem#

_images/wifi_problem.svg

Code#

examples/model/12_wifi_nullable_enum.py#
from hermax.model import Model


m = Model()

routers = [0, 1, 2, 3]
edges = [(0, 1), (1, 2), (2, 3), (0, 3)]
freqs = ["f1", "f2", "f3"]

state = m.enum_dict("router_state", routers, choices=freqs, nullable=True)

for u, v in edges:
    for f in freqs:
        m &= (~(state[u] == f) | ~(state[v] == f))

for r in routers:
    m.obj[5] += state[r].is_in(["f1", "f2", "f3"])
    m.obj[1] += ~(state[r] == "f1")
    m.obj[2] += ~(state[r] == "f2")
    m.obj[1] += ~(state[r] == "f3")

r = m.solve()

print("status:", r.status)
print("cost:", r.cost)
print("router_state:", r[state])

Output#

The result shows router states and the resulting objective cost.

$ python examples/model/12_wifi_nullable_enum.py
status: optimum
cost: 4
router_state: {0: 'f3', 1: 'f1', 2: 'f3', 3: 'f1'}

Solution#

_images/wifi_solution.svg

Next#

Advanced modelling examples continue in Advanced Modelling Examples.

References#