LoginSignup
2
4

More than 5 years have passed since last update.

シミュレーションマニア(2) レストランのホールタスクをシミュレーションで再現してみた

Last updated at Posted at 2019-04-11

はじめに

シミュレーションって面白い.ということで,前回はレストランでの座席案内を単純化してシミュレーションで再現してみた.今回は,レストランの店内に注目して,フロアタスクのシミュレーションに挑戦しよう.

モデルの概要

左端にキッチン,右端にレジがあり,その間に5台のテーブルが直列に並んでいるシンプルなレイアウトを考える.

まず顧客が空きテーブルに着席し,メニューを吟味し,注文を決めて手を挙げる.フロアスタッフがその注文を聞き,キッチンに伝えると,キッチンでその料理が調理され,仕上がると出てくる.フロアスタッフがその料理をテーブルに届けると,顧客はそれを食べ始める.

食事を終えると顧客はレジに進み,会計を済ませて退店する.フロアスタッフはレジに出向いて会計に対応し,食事の済んだテーブルから食器などをキッチンに片付ける.

この一連の流れの中で,スタッフは,注文の受付け(とキッチンへの報告),料理の(キッチンからの受取りと)配膳,食べ終わった食器の回収(とキッチンへの返却),会計処理といった作業をこなしていく.

実際の現場では,複数の作業をうまく組み合わせて実施してくことが有効になるが,まずは単純なケースとして,スタッフは,先着順(First-In-First-Out)で,これらの作業を1つずつ順にこなしていくと仮定しよう.

この条件でシミュレーションを行ってみたのが下の例である(いつか動画に差替えたい).

スクリーンショット 2019-04-11 22.17.52.png

顧客が注文を決めるまでの時間,キッチンでの調理時間,テーブルでの食事時間はそれぞれある正規分布に従う確率変数とし,スタッフがテーブル間を移動する時間,各作業をこなす時間は(短いので)それぞれ定数で設定している.

上半分の部分にある四角形はテーブルやキッチン,レジを表しており,それらをつなぐ経路上を動き回る青色の円がスタッフに対応している.

テーブルは,顧客が注文を決めるまでは白色,スタッフが対応している間は青色,食事中は緑色,顧客退席後でまだ食器が片付けられていない間は黒色でそれぞれ表示されるようになっている.また,顧客対応の作業(注文伺い,配膳)のニーズが生じると「待ち状態」になり,その間は,経過時間に従って徐々に濃くなる赤色で表示される.

キッチンやレジは,そこで実施すべき顧客対応の作業(配膳のための料理の受取り,会計処理)のニーズが存在しない間は白色で,ニーズが生じると上記と同様の「待ち状態」に変わる.

下半分に現れる図はガントチャートである.横軸が時間で,縦軸がテーブルやキッチン,レジ(そして,スタッフの動作)に対応しており,時間軸に沿った状態の変化の様子がわかる.

これは,IEの分野で作業の分析や改善のためによく用いられるマンマシンチャートであるともいえる.ただし,前回と同様に,飲食店などのサービスの文脈では,単なる作業効率の向上だけではなく,顧客の満足度(不満度?)にも気を配る必要がある.

このような点からも,サービス分野での作業分析に今回のようなシミュレーションを併用することの有用性が理解できる.

コードの概説

今回もここに書いたフレームワークに沿っており,前回のコードから流用している箇所も多い.コードの全体像は,このGitHubのリポジトリのsim/static/code/mania/code02.jsに置くので,必要に応じて参照してほしい.ここでは,主なポイントだけ見ておこう.

まず,テーブルやキッチン,レジのモデルとして,Counterを定義している.このオブジェクトは,状態(state)として,vacant(白色に対応),addressed(青色に対応),waiting(赤色に対応),eating(緑色に対応),left(黒色に対応)のいずれかの値をとり,状態遷移の履歴をログ(stateLog)に格納していくようになっている(履歴の扱いは後のOrderやClerkでもほぼ同様).ordersは対応する注文(Order)のリスト,nodeは経路上の地点(Node)へのポインタである.

Orderは顧客の注文のモデルである.これは,状態(state)として,born(メニューを決めた状態), addressed(テーブルやキッチンでスタッフが作業中), carried(スタッフが注文をキッチンに伝える途上), cooked(調理中), ready(仕上がった料理がキッチンに置かれた状態), delivered(配膳の途上), eaten(食事中), left(食後の食器がテーブルに置かれた状態), bassed(食器をキッチンに持ち帰る途上), vanished(食器を片付け終わった状態)のいずれかの値をとる.tableは対応するテーブル(Counter)へのポインタで,when_payedには会計処理の終了時刻を入れる.

// states: vacant(white), addressed(blue), waiting(red), eating(green), left(black)
function Counter(name) {
  this.name = name;
  this.node = undefined;
  this.orders = [];
  this.when = 0;
  this.state = "vacant";  // no order / no dishes / empty queue
  this.stateLog = ...
}

// states: born, addressed, carried, cooked, ready, delivered, eaten, left, bassed, vanished
function Order(when, table) {
  this.table = table;
  this.when = when;
  this.state = "born";
  this.when_payed = undefined;
  this.stateLog = ...
}

スタッフは,地点(Node)を一列につないだ経路(Path)上を移動する.今回のモデルでは,CounterとNodeを1対1に対応させ,双方向にポインタを張っている.にもかかわらず,それらをあえて別のオブジェクトとして実装しているのは,将来的な拡張のためである.

例えば,同じテーブルにアクセスできる地点が複数あったり,ある1つの地点から複数のテーブルにアクセスできるようなレイアウトもあり得る.また,PathはNodeのリストとして実装しているが,これをもう少し複雑なグラフ構造に拡張することも考えられる.

function Node(i, counter) {
  this.id = i;
  this.counter = counter;
}

function Path(counters) {  // 0: kitchen, NT +1: checkstand
  this.nodes = [];
  for(var i = 0; i < counters.length; i ++) {
    var node = new Node(i, counters[i])
    this.nodes.push(node);
    counters[i].node = node;
  }
}

スタッフの作業(Task)とそのリスト(Todo)のモデルは下記のようになっている.

Taskのtypeには,注文の受付け(take_order),そのキッチンへの伝達(put_order),料理の取得(take_dish,),そのテーブルへの配膳(put_dish),食器の取得(take_scrap),そのキッチンへの片付け(put_scrap),会計処理(checkout)のうちのいずれかの値が入る.

また,実施場所(Counter)をcounterに,対応する注文(Order)をorderに,ニーズが生じた時刻をwhenに,それぞれ格納するようになっている.whotodosはそれぞれClerk(下で説明する)とTodo(Taskのリスト)へのポインタであり,Taskからそれらの情報を直接参照できるようにしてある.

// types: take_order, put_order, take_dish, put_dish, take_scrap, put_scrap, checkout
function Task(counter, order, type, when, todos) {
  this.counter = counter;  // where the task should be done
  this.order = order;
  this.type = type;
  this.when = when;  // when this task is requested
  this.who = undefined;  // for future extension to multi-agent model
  this.list = todos;  // in which list this task is included
}

function Todo() {  // task list
  this.tasks = [];
}

Clerkはスタッフのモデルであり,状態(state)として,待機中(standby),移動中(moving),作業中(working)のいずれかの値を取る.また,今どの地点(Node)にいるかをlocationに入れる.保持している6つのTodoは,実施すべき作業の,type別に分類されたリストである.

下のset_destination()メソッドでは,これらのリストの中から次に着手すべき優先作業を決め,それをprimary_taskに入れるとともに,それを実施する地点(Node)をdestinationに入れている.現時点では,もし未伝達の注文,配膳中の料理,下膳中の食器のいずれかを保持していればそれを手放す作業を優先させ,そうでなければFIFOで優先作業を決めるという単純なルールになっている(が,ここは,興味に応じてぜひ拡張したいところである).

この意思決定の際にモデル全体の情報にアクセスしやすいように,Clerkは,モデルへのポインタ(model)をもっている(アニメーションの都合でモデル(my_model)は大域変数にしてあるので,今回の実装自体ではこのポインタは無くてもよいのだが).

function Clerk(model, loc) {
  this.model = model;
  this.location = loc;  // node
  this.state = undefined;  // state: standby, moving, working
  this.primary_task = undefined;  // task
  this.destination = undefined;  // node
  this.put_orders = new Todo();  // orders taken and still held
  this.put_dishes = new Todo();  // dishes on the tray
  this.put_scraps = new Todo();  // scraps on the tray
  this.dishes_ready = new Todo();  // cooked dishes at kitchen
  this.checkouts = new Todo();  // checkstand queue
  this.table_tasks = new Todo();  // may be further divided
  this.stateLog = ...
}

Clerk.prototype.set_destination = function() {  // this may be refined
  if(this.put_dishes.tasks.length > 0) {
    this.primary_task = this.put_dishes.tasks[0];
  } else if(this.put_scraps.tasks.length > 0) {
    this.primary_task = this.put_scraps.tasks[0];
  } else if(this.put_orders.tasks.length > 0) {
    this.primary_task = this.put_orders.tasks[0];
  } else {
    this.primary_task = undefined;
    var when_requested = this.model.par.HRZ;
    if(this.checkouts.tasks.length > 0 && this.checkouts.tasks[0].when < when_requested) {
      this.primary_task = this.checkouts.tasks[0]
      when_requested = this.primary_task.when;
    }
    if(this.dishes_ready.tasks.length > 0 && this.dishes_ready.tasks[0].when < when_requested) {
      this.primary_task = this.dishes_ready.tasks[0]
      when_requested = this.primary_task.when;
    }
    if(this.table_tasks.tasks.length > 0 && this.table_tasks.tasks[0].when < when_requested) {
      this.primary_task = this.table_tasks.tasks[0]
      when_requested = this.primary_task.when;
    }
  }
  if(this.primary_task == undefined) {
    this.destination = undefined;
  } else {
    this.destination = this.primary_task.counter.node;
  }
}

ここまでで述べたパーツを組み合わせて,レストラン全体のモデルを下のように実装している.parには諸々のパラメータを入れてあるが,下記では詳細は省略した.キッチン(kitchen)とテーブル(tables)とレジ(checkstand)をCounterオブジェクトとして用意して,それらに対応したPathを定義しているのがわかる.あとは,スタッフ(clerk)とイベントカレンダ(calendar)である.

カレンダには,初期値として,スタッフが時刻0にキッチンに到着するイベント(arrive),シミュレーションを終了させるイベント(over),各テーブルに注文が生起するイベント(create_order)を含めている.また,後で見るように,これら以外に生じ得るイベントとして,作業終了(complete_task),調理完了(finish_cooking),食事終了(eat_up)の3つがある.

// simulation model
function Model() {
  this.par = { ... };
  this.kitchen = new Counter("kitchen");
  this.tables = [];
  for(var i = 1; i <= this.par.NT; i ++) {
    this.tables.push(new Counter("table" +i));
  }
  this.checkstand = new Counter("checkstand");
  this.path = new Path([this.kitchen].concat(this.tables).concat([this.checkstand]));
  this.clerk = new Clerk(this, this.kitchen.node);
  this.calendar = new Calendar([
    {time: 0, type: "arrive", at: this.kitchen.node},  // ready to start from the kitchen
    {time: this.par.HRZ, type: "over"}  // the end of simulation
  ]);
  for(var table of this.tables) {
    this.calendar.extend({
      time: tnorm_rand(this.par.MTR, this.par.SDR),
      type: "create_order",
      table: table
    });
  }
}

シミュレーションでは,このモデルの状態をイベント駆動で変化させていく.この役割を担うのが下のupdate()メソッドである.すべてのイベントのタイプに対する処理を書いているので少し長くなっている(から,そろそろ分割したほうがいいと思う)が,if文で場合分けしてあるので,場合ごとに見ていこう.

まず,overの場合は,単にnoLoop()を呼んでdraw()のループを停止させているだけである.create_orderの場合は,対応するテーブルにOrderを追加し,それを聞きに行く作業を,対応するTodoのリストに追加している.finish_cookingの場合は,完成した料理を取りに行く作業をTodoリストに追加するとともに,キッチンの状態を更新している.eat_upの場合は,食後の食器の回収と会計処理をTodoリストに追加して,対応するテーブルとレジの状態を更新している.

少し複雑なのがarriveの場合である.この部分で,スタッフ(clerk)の動きをほぼコントロールしている.最初に,if文で,destinationが決まっているかどうかの場合分けを行っている.

決まっていない場合は,set_destination()を呼んでそれを決めようとするのだけれど,それでも決まらなかった(すなわち,やるべき作業が存在しなかった)場合はclerkをstandby状態にする(なお,何か別のイベントが生じた際にclerkをstandby状態から復帰させる処理がupdate()の3行目から書かれている).

決まった場合は,新たなdestinationの情報を持ってもう一度,同じ時刻,同じ地点にarriveするイベントをカレンダに追加している.

destinationが決まっている場合は,その地点で作業に取り掛かるのか,作業はせずにそのまま次の地点に向かうのかの判断を下す.この際,今の地点がdestinationであれば当然,primary_taskに取り掛かるのだが,そうでない場合にも副次的な作業を実施することがあり得る(例えば,注文をキッチンに伝える途上で他のテーブルの注文を聞くなど).choose_task()は,この判断のためのメソッドである(が,今回は副次的な作業は行わない設定にしているため,単にundefinedを返す実装になっている).

ここで作業に取り掛かることになった場合は,対応するTaskのstart()メソッドを呼び,complete_taskのイベントをカレンダに追加している.また,complete_taskが生起した場合は,対応するTaskのcomplete()メソッドを呼んでいる.

Model.prototype.update = function() {
  var e = this.calendar.fire();  // e: the next event
  if(e.type != "arrive" && this.clerk.state == "standby") {  // standby clerk is activated when something happens
    this.calendar.extend({
      time: e.time,
      type: "arrive",
      at: this.clerk.location
    });
  }
  if(e.type == "over") {  // simulation is over
    noLoop();
  } else if(e.type == "create_order") {  // a customer has chosen a menu to order
    var o = e.table.create_order(e.time);
    this.clerk.table_tasks.add_task(new Task(e.table, o, "take_order", e.time, this.clerk.table_tasks));
  } else if(e.type == "arrive") {  // the clerk has arrived at a node
    this.clerk.location = e.at;
    if(this.clerk.destination == undefined) {
      this.clerk.set_destination();
      if(this.clerk.destination == undefined) {  // no place to go
        this.clerk.change_state(e.time, "standby");  // wait here for a moment
      } else {
        this.calendar.extend({  // arrive at the same node and the same time
          time: e.time,
          type: "arrive",
          at: e.at
        });
      }
    } else {
      if(this.clerk.destination == this.clerk.location) {
        var do_it = this.clerk.primary_task;
      } else {
        var do_it = this.clerk.choose_task();
      }
      if(do_it == undefined) {
        this.clerk.change_state(e.time, "moving");  // move to the next node
        this.calendar.extend({
          time: e.time +this.par.WT,
          type: "arrive",
          at: this.clerk.choose_next_node()
        });
      } else {
        this.clerk.change_state(e.time, "working");  // carry out a task
        do_it.start(e.time, this.clerk);
        this.calendar.extend({
          time: e.time +this.par.AT,
          type: "complete_task",
          at: do_it.counter.node,
          task: do_it
        });
      }
    }
  } else if(e.type == "complete_task") {  // the clerk has finished a task
    e.task.complete(e.time);
    this.calendar.extend({  // arrive at the same node and the same time
      time: e.time,
      type: "arrive",
      at: e.at
    });
    if(e.task == this.clerk.primary_task) {
      this.clerk.primary_task = undefined;
      this.clerk.destination = undefined;
    }
  } else if(e.type == "finish_cooking") {  // a dish has been cooked ready
    this.clerk.dishes_ready.add_task(new Task(this.kitchen, e.order, "take_dish", e.time, this.clerk.dishes_ready));
    this.kitchen.change_state(e.time, "waiting");
    this.kitchen.orders.push(e.order);
  } else if(e.type == "eat_up") {  // a customer finishes eating
    this.clerk.table_tasks.add_task(new Task(e.order.table, e.order, "take_scrap", e.time, this.clerk.table_tasks));
    this.clerk.checkouts.add_task(new Task(this.checkstand, e.order, "checkout", e.time, this.clerk.checkouts));
    e.table.change_state(e.time, "left");
    this.checkstand.change_state(e.time, "waiting");
    this.checkstand.orders.push(e.order);
  }
}

最後に,Taskのstart()complete()のメソッドを見ておこう.詳しい説明は省略するが,いずれにも作業の開始と完了に伴う諸々の状態変化を記述してある.また,complete()の方では,take_somethingのタイプの作業が完了すると対応するput_somethingのタイプの作業をTodoリストに追加していることがわかる.

また,put_orderが完了すると調理が始まるので,finish_cookingイベントを,put_dishが完了すると食事が始まるので,eat_upイベントを,put_scrapが完了すると次の顧客が案内されてくるので,create_orderイベントを,それぞれカレンダに追加する必要があるが,それらはOrderのchange_state()メソッドの中で行っている.

Task.prototype.start = function(when, who) {
  this.who = who;
  this.list.remove_task(this);  // remove it from task list
  this.counter.change_state(when, "addressed");
  if(this.type != "checkout") {
    this.order.change_state(when, "addressed");
  }
  if(this.type == "checkout" || this.type == "take_dish") {
    this.counter.remove_order(this.order);
  }
}

Task.prototype.complete = function(when) {
  if(this.type == "take_order") {
    this.counter.change_state(when, "waiting");
    this.order.change_state(when, "carried");
    this.who.put_orders.add_task(new Task(this.who.model.kitchen, this.order, "put_order", when, this.who.put_orders));
  } else if(this.type == "put_order") {
    if(this.counter.orders.length > 0) {  // there are ready dishes at kitchen
      this.counter.change_state(when, "waiting");
    } else {
      this.counter.change_state(when, "vacant");
    }
    this.order.change_state(when, "cooked");
  } else if(this.type == "take_dish") {
    if(this.counter.orders.length > 0) {  // there are ready dishes at kitchen
      this.counter.change_state(when, "waiting");
    } else {
      this.counter.change_state(when, "vacant");
    }
    this.order.change_state(when, "delivered");
    this.who.put_dishes.add_task(new Task(this.order.table, this.order, "put_dish", when, this.who.put_dishes));
  } else if(this.type == "put_dish") {
    this.counter.change_state(when, "eating");
    this.order.change_state(when, "eaten");
  } else if(this.type == "take_scrap") {
    this.counter.change_state(when, "vacant");
    this.order.change_state(when, "bassed");
    this.who.put_scraps.add_task(new Task(this.who.model.kitchen, this.order, "put_scrap", when, this.who.put_scraps));
  } else if(this.type == "put_scrap") {
    if(this.counter.orders.length > 0) {  // there are ready dishes at kitchen
      this.counter.change_state(when, "waiting");
    } else {
      this.counter.change_state(when, "vacant");
    }
    this.order.change_state(when, "vanished");
  } else if(this.type == "checkout") {
    if(this.counter.orders.length > 0) {  // checkstand queue is not empty
      this.counter.change_state(when, "waiting");
    } else {
      this.counter.change_state(when, "vacant");
    }
    this.order.when_payed = when;
  }
}

まとめ

今回は,レストランのフロアタスクを簡単にモデル化して,シミュレーションで再現してみた.前回と同じく,このモデルを実態に応じて拡張すれば,実店舗での業務分析や,担当者のスキル評価や習熟支援などにも活用できる可能性があるかもしれない.

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4