DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Smart Brewery: a digital twin brewery as a PON lab

If this helped you, you can support the author with a coffee on dev.to.

Smart Brewery: a digital twin brewery as a PON lab

Part 5 of 12Part 4 on dev.to — Hexagonal architecture + PON: Ports & Adapters to decouple the engine · repo draft separated ports from adapters so rule side effects stay testable. This post switches from toy examples to a single, opinionated lab: a digital twin of a brewery line implemented as a PON graph—dozens of facts, cross-equipment rules, scripted scenarios, and a hybrid simulator (physics-ish models + Monte Carlo noise) that pounds the engine the way a real plant telemetry stream would. Industry vocabulary for “twin + physical asset + lifecycle data” was popularized in part by Grieves’s digital-manufacturing framing (often cited as Digital Twin: Manufacturing Excellence Through Virtual Factory Replication, 2014); our twin is a software-only stress lab, not a certified plant model.

The code lives in the monorepo: core facts and rules under tec0301_pon, continuous simulation under the simulacoes_visuais Phoenix app.


What we are simulating

The twin groups 57 facts into eleven functional block elements (FBEs)—mill, mash, filter, boil, heat exchanger, two fermenters, packaging, CIP, AMR fleet, and a smart grid slice. Twelve rules (R_01–R_12) encode operational and safety logic: filtration optimization, packaging interlocks, peak-load shifting, ISO 10816-3 mill protection, NR-13 boiler limits, ISA-88 mash–filter coordination, AMR battery policy, and grid resilience—see the module documentation in Tec0301Pon.Examples.SmartBrewery and the full attribute/rule catalog in docs/smart-brewery-fatos-regras.md (repository root docs/).

FBE Name (concept) Fact count
01 Mill / grist 5
02 Mash tun 6
03 Lauter / filter 5
04 Boil kettle 5
05 Heat exchanger 4
06 Fermenter A 7
07 Fermenter B 6
08 Bottling line 6
09 CIP 5
10 AMR fleet 5
11 Smart grid 4

Total: 57 facts, all atoms backed by Tec0301Pon.PON.Fato processes, with the usual Registry fan-out from Parts 2–3.

Bootstrapping the graph

Tec0301Pon.Examples.SmartBrewery.start_link/0 starts every fact from the initial-value list, then starts each generated rule module (R_01 … R_12):

# lib/tec0301_pon/examples/smart_brewery.ex (conceptual excerpt)
def start_link do
  for {nome, valor} <- @fatos_iniciais do
    Fato.start_link(nome, valor)
  end

  Tec0301Pon.Examples.SmartBrewery.Regras.RegraOtimizacaoFiltracao.start_link()
  Tec0301Pon.Examples.SmartBrewery.Regras.RegraIntertravamentoEnvase.start_link()
  Tec0301Pon.Examples.SmartBrewery.Regras.RegraSmartGridLoadBalancing.start_link()
  # ... remaining rules R_04 – R_12
  {:ok, self()}
end
Enter fullscreen mode Exit fullscreen mode

In the Phoenix app, a bridge supervisor typically hosts this graph so LiveView and the Monte Carlo loop see running processes (SimulacoesVisuais.Application children—Part 6 on dev.to focuses on the UI).

Rules in the wild: R_01 and R_04

Rules use Tec0301Pon.PON.Builder (defrule). R_01 tightens filtration when differential pressure is high, clarity is poor, and pump speed is aggressive:

# lib/tec0301_pon/examples/smart_brewery_regras.ex (excerpt — R_01)
defrule(RegraOtimizacaoFiltracao,
  watch: [:fbe_03_diff_pressure, :fbe_03_wort_clarity, :fbe_03_pump_speed],
  when:
    memoria[:fbe_03_diff_pressure] > 150 and memoria[:fbe_03_wort_clarity] < 20 and
      memoria[:fbe_03_pump_speed] > 50,
  do:
    (
      Tec0301Pon.Examples.SmartBrewery.FBE_03.reduce_pump_10pct()
      Tec0301Pon.Examples.SmartBrewery.FBE_03.lower_rake_position()
      Tec0301Pon.Examples.SmartBrewery.RegraNotifier.notify(:r_01)
    )
)
Enter fullscreen mode Exit fullscreen mode

R_04 protects the mill using edge_triggered: true so the action does not re-fire on every notification while the condition stays true (Part 3 on dev.to):

# lib/tec0301_pon/examples/smart_brewery_regras.ex (excerpt — R_04)
defrule(RegraProtecaoMoinho,
  watch: [
    :fbe_01_motor_temp,
    :fbe_01_vibration_level,
    :fbe_01_hopper_level,
    :fbe_01_motor_rpm,
    :fbe_01_feed_valve_state
  ],
  when:
    (memoria[:fbe_01_vibration_level] != nil and memoria[:fbe_01_vibration_level] > 80) or
      (memoria[:fbe_01_motor_temp] != nil and memoria[:fbe_01_motor_temp] > 70) or
      (memoria[:fbe_01_hopper_level] != nil and memoria[:fbe_01_motor_rpm] != nil and
         memoria[:fbe_01_hopper_level] < 15 and memoria[:fbe_01_motor_rpm] > 0),
  edge_triggered: true,
  do:
    (
      Tec0301Pon.Examples.SmartBrewery.FBE_01.reduce_motor_rpm()

      if memoria[:fbe_01_vibration_level] != nil and memoria[:fbe_01_vibration_level] > 95,
        do: Tec0301Pon.Examples.SmartBrewery.FBE_01.close_feed_valve()

      Tec0301Pon.Examples.SmartBrewery.RegraNotifier.notify(:r_04)
    )
)
Enter fullscreen mode Exit fullscreen mode

FBE_XX modules update facts to represent equipment response; RegraNotifier feeds observability (PubSub / persistence hooks in the full app).

Scripted walkthrough: simular/0

For demos and tests, SmartBrewery.simular/0 drives a narrative sequence of Fato.atualizar/2 calls to trigger R_01, then R_02, then R_03:

# lib/tec0301_pon/examples/smart_brewery.ex (excerpt)
def simular do
  Fato.atualizar(:fbe_03_wort_clarity, 15)
  Fato.atualizar(:fbe_03_pump_speed, 60)
  # ...
  Fato.atualizar(:fbe_03_diff_pressure, 152)
  # ...
  Fato.atualizar(:fbe_08_capper_jam_sens, true)
  # ...
  Fato.atualizar(:fbe_09_cip_pump_state, :on)
  Fato.atualizar(:fbe_11_grid_power_cost, 180)
end
Enter fullscreen mode Exit fullscreen mode

This is the inbound adapter story from Part 4 in concrete form: something external (here, a script) pushes world state; rules react.

Continuous stress: hybrid Monte Carlo

Long-running exploration uses SimulacoesVisuais.SmartBreweryMonteCarlo—a GenServer that schedules ticks. Each tick runs dedicated models for key subsystems, then applies stochastic updates to a subset of remaining facts according to a schema ({:range, min, max}, {:enum, [...]}, :bool). Some continuous variables use a random walk (Gaussian step) instead of i.i.d. uniforms.

# apps/simulacoes_visuais/lib/.../smart_brewery_monte_carlo.ex (excerpt)
def run_tick_pure(state) do
  FBE03Darcy.tick()
  FBE06Fermentation.tick()
  FBE07Fermentation.tick()
  FBE08Markov.tick()
  FBE10Markov.tick()
  FBE11SmartGrid.tick()
  state = update_fbe03_cholesky(state)
  # pick N fact names from @mc_fact_names, then:
  apply_mc_chosen_updates(chosen)
  state
end

defp apply_mc_chosen_updates([nome | rest]) do
  valor = next_value(nome)
  if valor != nil do
    Fato.atualizar(nome, valor)
  end
  apply_mc_chosen_updates(rest)
end
Enter fullscreen mode Exit fullscreen mode

Scheduling:

def handle_info(:tick, state) do
  new_state = run_tick_pure(state)
  Process.send_after(self(), :tick, state.interval_ms)
  {:noreply, new_state}
end
Enter fullscreen mode Exit fullscreen mode

Facts owned by the dedicated models are excluded from the naive Monte Carlo list (@excluded_from_mc) so two writers do not fight. The LiveView Start / Stop Monte Carlo buttons call SmartBreweryMonteCarlo.start_loop/0 and stop_loop/0 (Part 6 on dev.to).

flowchart LR
  subgraph sim [Simulation_tick]
    MC[MonteCarlo_subset]
    M03[FBE03Darcy]
    M06[FBE06Fermentation]
    M08[FBE08Markov]
  end
  Fato[Tec0301Pon_Fato]
  Reg[Registry_PubSub]
  Regra[Regra_processes]
  FBE[FBE_side_effects]
  sim --> Fato
  Fato --> Reg
  Reg --> Regra
  Regra --> FBE
  Regra --> Notif[RegraNotifier]
  subgraph later [Later_posts]
    LV[LiveView]
    TSDB[TSDB_writers]
  end
  Notif -.-> LV
  Notif -.-> TSDB
Enter fullscreen mode Exit fullscreen mode

Telemetry, TSDB, and the Phoenix app (teaser)

With :tsdb_enabled, the simulacoes_visuais app starts Ecto + async writers (e.g. rule events) fed from the operational pipeline—Part 7 on dev.to covers Broadway, batching, and TimescaleDB in depth. Part 6 on dev.to covers LiveView (SmartBreweryLive), streams, and operator UX.

Heads-up: a root mix run examples/smart_brewery_simulacao.exs path may start only :tec0301_pon and not persist to telemetry_events; full TSDB behavior requires the Phoenix app with the right config—see apps/simulacoes_visuais/README.md.

Summary

The Smart Brewery twin is a deliberately large PON graph: many facts, cross-FBE rules, FBE modules that close the loop on state, plus two simulation modes—a scripted simular/0 for storytelling and a hybrid Monte Carlo loop for sustained load. It is the same engine you built in Parts 1–4, now exercised like a product.

References and further reading

  • Grieves, M.Digital Twin: Manufacturing Excellence Through Virtual Factory Replication (2014 white paper; search institutional copies) — vocabulary for twins in manufacturing.
  • Simão et al. (2013) — NOP / notification-oriented control — SCIRP (conceptual link to PON-style updates).
  • In this repodocs/smart-brewery-fatos-regras.md; lib/tec0301_pon/examples/smart_brewery.ex, smart_brewery_regras.ex, smart_brewery_fbe.ex; apps/simulacoes_visuais/lib/simulacoes_visuais/smart_brewery_monte_carlo.ex; SimulacoesVisuaisWeb.SmartBreweryLive. Expanded list: Bibliography on dev.to — PON + Smart Brewery series (EN drafts) · repo draft.

Published on dev.to: Smart Brewery: a digital twin brewery as a PON lab — tracked in docs/devto_serie_pon_smart_brewery.md.

Previous: Part 4 on dev.to — Hexagonal architecture + PON: Ports & Adapters to decouple the engine · repo draft

Next: Part 6 on dev.to — Phoenix LiveView in real time: an operations UI on top of a rules engine · repo draft.

Top comments (0)