Persist and expose via API the progress of the tables arrangement simulations #316
@ -9,10 +9,10 @@ class TablesArrangementsController < ApplicationController
|
|||||||
render json: TablesArrangement
|
render json: TablesArrangement
|
||||||
.order(valid: :desc)
|
.order(valid: :desc)
|
||||||
.order(discomfort: :asc)
|
.order(discomfort: :asc)
|
||||||
.select(:id, :name, :discomfort)
|
.select(:id, :name, :discomfort, :status, :progress)
|
||||||
.select("digest = '#{current_digest}'::uuid as valid")
|
.select("digest = '#{current_digest}'::uuid OR discomfort IS NULL as valid")
|
||||||
.limit(20)
|
.limit(20)
|
||||||
.as_json(only: %i[id name discomfort valid])
|
.as_json(only: %i[id name discomfort valid status progress])
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ -25,7 +25,10 @@ class TablesArrangementsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
TableSimulatorJob.perform_later(current_tenant.id)
|
ActiveRecord::Base.transaction do
|
||||||
|
tables_arrangement = TablesArrangement.create!(status: :not_started)
|
||||||
|
TableSimulatorJob.perform_later(current_tenant.id, tables_arrangement.id)
|
||||||
|
end
|
||||||
|
|
||||||
render json: {}, status: :created
|
render json: {}, status: :created
|
||||||
end
|
end
|
||||||
|
@ -8,16 +8,35 @@ class TableSimulatorJob < ApplicationJob
|
|||||||
MIN_PER_TABLE = 8
|
MIN_PER_TABLE = 8
|
||||||
MAX_PER_TABLE = 10
|
MAX_PER_TABLE = 10
|
||||||
|
|
||||||
def perform(wedding_id)
|
def perform(wedding_id, tables_arrangement_id)
|
||||||
|
Rails.logger.info "Starting table simulation #{tables_arrangement_id} for wedding #{wedding_id}"
|
||||||
ActsAsTenant.with_tenant(Wedding.find(wedding_id)) do
|
ActsAsTenant.with_tenant(Wedding.find(wedding_id)) do
|
||||||
engine = VNS::Engine.new
|
engine = VNS::Engine.new
|
||||||
|
|
||||||
engine.add_optimization(Tables::Swap)
|
engine.add_optimization(Tables::Swap)
|
||||||
engine.add_optimization(Tables::Shift)
|
engine.add_optimization(Tables::Shift)
|
||||||
|
|
||||||
initial_solution = Tables::Distribution.new(min_per_table: MIN_PER_TABLE, max_per_table: MAX_PER_TABLE)
|
tables_arrangement = TablesArrangement.find(tables_arrangement_id)
|
||||||
|
|
||||||
|
initial_solution = Tables::Distribution.new(
|
||||||
|
min_per_table: MIN_PER_TABLE,
|
||||||
|
max_per_table: MAX_PER_TABLE,
|
||||||
|
tables_arrangement_id:
|
||||||
|
)
|
||||||
|
|
||||||
initial_solution.random_distribution(Guest.potential.shuffle)
|
initial_solution.random_distribution(Guest.potential.shuffle)
|
||||||
|
|
||||||
|
initial_solution.save!
|
||||||
|
|
||||||
|
engine.notify_progress do |current_progress|
|
||||||
|
tables_arrangement.update_columns(status: :in_progress, progress: current_progress)
|
||||||
|
end
|
||||||
|
|
||||||
|
engine.on_better_solution do |better_solution|
|
||||||
|
better_solution.save!
|
||||||
|
tables_arrangement.update_columns(discomfort: better_solution.discomfort) # TODO: remove?
|
||||||
|
end
|
||||||
|
|
||||||
engine.initial_solution = initial_solution
|
engine.initial_solution = initial_solution
|
||||||
|
|
||||||
engine.target_function(&:discomfort)
|
engine.target_function(&:discomfort)
|
||||||
@ -25,6 +44,8 @@ class TableSimulatorJob < ApplicationJob
|
|||||||
best_solution = engine.run
|
best_solution = engine.run
|
||||||
|
|
||||||
best_solution.save!
|
best_solution.save!
|
||||||
|
|
||||||
|
tables_arrangement.update_columns(status: :completed)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -29,5 +29,5 @@
|
|||||||
class Seat < ApplicationRecord
|
class Seat < ApplicationRecord
|
||||||
acts_as_tenant :wedding
|
acts_as_tenant :wedding
|
||||||
belongs_to :guest
|
belongs_to :guest
|
||||||
belongs_to :table_arrangement
|
belongs_to :tables_arrangement
|
||||||
end
|
end
|
||||||
|
@ -10,6 +10,8 @@
|
|||||||
# digest :uuid not null
|
# digest :uuid not null
|
||||||
# discomfort :integer
|
# discomfort :integer
|
||||||
# name :string not null
|
# name :string not null
|
||||||
|
# progress :float default(0.0), not null
|
||||||
|
# status :string default("complete"), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# wedding_id :uuid not null
|
# wedding_id :uuid not null
|
||||||
|
@ -12,13 +12,14 @@ module Tables
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy
|
attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy, :tables_arrangement_id
|
||||||
|
|
||||||
def initialize(min_per_table:, max_per_table:, hierarchy: AffinityGroupsHierarchy.new)
|
def initialize(min_per_table:, max_per_table:, tables_arrangement_id:, hierarchy: AffinityGroupsHierarchy.new)
|
||||||
@min_per_table = min_per_table
|
@min_per_table = min_per_table
|
||||||
@max_per_table = max_per_table
|
@max_per_table = max_per_table
|
||||||
@hierarchy = hierarchy
|
@hierarchy = hierarchy
|
||||||
@tables = []
|
@tables = []
|
||||||
|
@tables_arrangement_id = tables_arrangement_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def random_distribution(people, random: Random.new)
|
def random_distribution(people, random: Random.new)
|
||||||
@ -42,15 +43,23 @@ module Tables
|
|||||||
end
|
end
|
||||||
|
|
||||||
def deep_dup
|
def deep_dup
|
||||||
self.class.new(min_per_table: @min_per_table, max_per_table: @max_per_table,
|
self.class.new(
|
||||||
hierarchy: @hierarchy).tap do |new_distribution|
|
min_per_table: @min_per_table,
|
||||||
|
max_per_table: @max_per_table,
|
||||||
|
hierarchy: @hierarchy,
|
||||||
|
tables_arrangement_id: @tables_arrangement_id
|
||||||
|
).tap do |new_distribution|
|
||||||
new_distribution.tables = @tables.map(&:dup)
|
new_distribution.tables = @tables.map(&:dup)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def save!
|
def save!
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
arrangement = TablesArrangement.create!
|
arrangement = TablesArrangement.find(tables_arrangement_id)
|
||||||
|
|
||||||
|
self.tables_arrangement_id = arrangement.id
|
||||||
|
|
||||||
|
arrangement.seats.delete_all
|
||||||
|
|
||||||
records_to_store = []
|
records_to_store = []
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
module VNS
|
module VNS
|
||||||
class Engine
|
class Engine
|
||||||
PERTURBATION_SIZES = [1, 1, 1, 2, 2, 3].freeze
|
PERTURBATION_SIZES = [1, 1, 1, 2, 2, 3].freeze
|
||||||
|
ITERATIONS = 50
|
||||||
class << self
|
class << self
|
||||||
def sequence(elements)
|
def sequence(elements)
|
||||||
elements = elements.to_a
|
elements = elements.to_a
|
||||||
@ -26,6 +27,14 @@ module VNS
|
|||||||
@perturbations << klass
|
@perturbations << klass
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def notify_progress(&block)
|
||||||
|
@progress_notifier = block
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_better_solution(&block)
|
||||||
|
@better_solution_notifier = block
|
||||||
|
end
|
||||||
|
|
||||||
attr_writer :initial_solution
|
attr_writer :initial_solution
|
||||||
|
|
||||||
def run
|
def run
|
||||||
@ -40,18 +49,24 @@ module VNS
|
|||||||
|
|
||||||
run_all_optimizations
|
run_all_optimizations
|
||||||
|
|
||||||
|
@progress_notifier&.call(Rational(1, ITERATIONS + 1))
|
||||||
|
|
||||||
best_solution = @current_solution
|
best_solution = @current_solution
|
||||||
|
|
||||||
50.times do
|
(1..ITERATIONS).each do |iteration|
|
||||||
@current_solution = Tables::WheelSwap.new(best_solution).call(PERTURBATION_SIZES.sample)
|
@current_solution = Tables::WheelSwap.new(best_solution).call(PERTURBATION_SIZES.sample)
|
||||||
@best_score = @target_function.call(@current_solution)
|
@best_score = @target_function.call(@current_solution)
|
||||||
Rails.logger.debug { "After perturbation: #{@best_score}" }
|
Rails.logger.debug { "After perturbation: #{@best_score}" }
|
||||||
|
|
||||||
run_all_optimizations
|
run_all_optimizations
|
||||||
|
|
||||||
|
@progress_notifier&.call(Rational(iteration + 1, ITERATIONS + 1))
|
||||||
|
|
||||||
next unless best_solution.discomfort > @current_solution.discomfort
|
next unless best_solution.discomfort > @current_solution.discomfort
|
||||||
|
|
||||||
best_solution = @current_solution
|
best_solution = @current_solution
|
||||||
|
@better_solution_notifier&.call(best_solution)
|
||||||
|
|
||||||
Rails.logger.debug do
|
Rails.logger.debug do
|
||||||
"Found better solution after perturbation optimization: #{@current_solution.discomfort}"
|
"Found better solution after perturbation optimization: #{@current_solution.discomfort}"
|
||||||
end
|
end
|
||||||
|
1
bin/jobs
1
bin/jobs
@ -3,4 +3,5 @@
|
|||||||
require_relative "../config/environment"
|
require_relative "../config/environment"
|
||||||
require "solid_queue/cli"
|
require "solid_queue/cli"
|
||||||
|
|
||||||
|
SolidQueue.logger = ActiveSupport::Logger.new($stdout)
|
||||||
SolidQueue::Cli.start(ARGV)
|
SolidQueue::Cli.start(ARGV)
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
class AddStatusColumnToTablesArrangements < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :tables_arrangements, :status, :string, default: :complete, null: false
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,5 @@
|
|||||||
|
class AddProgressToTablesArrangements < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :tables_arrangements, :progress, :float, default: 0, null: false
|
||||||
|
end
|
||||||
|
end
|
4
db/schema.rb
generated
4
db/schema.rb
generated
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_06_08_181054) do
|
ActiveRecord::Schema[8.0].define(version: 2025_09_08_145119) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
@ -216,6 +216,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_08_181054) do
|
|||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.uuid "wedding_id", null: false
|
t.uuid "wedding_id", null: false
|
||||||
t.uuid "digest", default: -> { "gen_random_uuid()" }, null: false
|
t.uuid "digest", default: -> { "gen_random_uuid()" }, null: false
|
||||||
|
t.string "status", default: "complete", null: false
|
||||||
|
t.float "progress", default: 0.0, null: false
|
||||||
t.index ["wedding_id"], name: "index_tables_arrangements_on_wedding_id"
|
t.index ["wedding_id"], name: "index_tables_arrangements_on_wedding_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -86,7 +86,9 @@ ActsAsTenant.with_tenant(wedding) do
|
|||||||
|
|
||||||
# TODO: Clean up invitations with no guests
|
# TODO: Clean up invitations with no guests
|
||||||
|
|
||||||
ActiveJob.perform_all_later(3.times.map { TableSimulatorJob.new(wedding.id) })
|
3.times { TablesArrangement.create! }
|
||||||
|
.map { |arrangement| TableSimulatorJob.new(wedding.id, arrangement.id) }
|
||||||
|
.then { |jobs| ActiveJob.perform_all_later }
|
||||||
|
|
||||||
"red".dup.paint.palette.triad(as: :hex).zip(Group.roots).each { |(color, group)| group.update!(color: color.paint.desaturate(40)) }
|
"red".dup.paint.palette.triad(as: :hex).zip(Group.roots).each { |(color, group)| group.update!(color: color.paint.desaturate(40)) }
|
||||||
|
|
||||||
|
@ -21,6 +21,14 @@ namespace :vns do
|
|||||||
|
|
||||||
engine.target_function(&:discomfort)
|
engine.target_function(&:discomfort)
|
||||||
|
|
||||||
|
engine.notify_progress do |current_progress|
|
||||||
|
Rails.logger.info "Progress: #{(current_progress * 100.0).round(2)}%"
|
||||||
|
end
|
||||||
|
|
||||||
|
engine.on_better_solution do |better_solution|
|
||||||
|
Rails.logger.info "New best solution found with discomfort: #{better_solution.discomfort}"
|
||||||
|
end
|
||||||
|
|
||||||
solution = Rails.benchmark('VNS Benchmarking') { engine.run }
|
solution = Rails.benchmark('VNS Benchmarking') { engine.run }
|
||||||
|
|
||||||
Rails.logger.info "Best solution found with discomfort: #{solution.discomfort}"
|
Rails.logger.info "Best solution found with discomfort: #{solution.discomfort}"
|
||||||
|
@ -12,6 +12,11 @@ server {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /jobs/ {
|
||||||
|
proxy_pass http://backend:3000/jobs/;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
}
|
||||||
|
|
||||||
location /captcha/v2/media/ {
|
location /captcha/v2/media/ {
|
||||||
proxy_pass http://libre-captcha:8888/v2/media/;
|
proxy_pass http://libre-captcha:8888/v2/media/;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
|
@ -19,7 +19,8 @@ RSpec.describe 'tables_arrangements' do
|
|||||||
id: { type: :string, format: :uuid },
|
id: { type: :string, format: :uuid },
|
||||||
name: { type: :string },
|
name: { type: :string },
|
||||||
discomfort: { type: :integer },
|
discomfort: { type: :integer },
|
||||||
valid: { type: :boolean }
|
valid: { type: :boolean },
|
||||||
|
status: { type: :string, enum: %w[complete in_progress] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xit
|
xit
|
||||||
|
@ -6,6 +6,39 @@ require 'rails_helper'
|
|||||||
|
|
||||||
module Tables
|
module Tables
|
||||||
RSpec.describe Distribution do
|
RSpec.describe Distribution do
|
||||||
|
describe '#save!' do
|
||||||
|
|
||||||
|
around do |example|
|
||||||
|
ActsAsTenant.with_tenant(create(:wedding)) do
|
||||||
|
example.run
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:people) { create_list(:guest, 2, status: :invited) }
|
||||||
|
let(:distribution) do
|
||||||
|
described_class.new(min_per_table: 5, max_per_table: 10).tap{|d| d.random_distribution(people)}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when tables_arrangement_id is nil' do
|
||||||
|
|
||||||
|
|
||||||
|
it { expect { distribution.save! }.to change { TablesArrangement.count }.by(1) }
|
||||||
|
it { expect { distribution.save! }.to change { Seat.count }.by(2) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when tables_arrangement_id is set' do
|
||||||
|
before do
|
||||||
|
existing_arrangement = TablesArrangement.create!
|
||||||
|
|
||||||
|
existing_arrangement.seats.create!(guest: people.first, table_number: 1)
|
||||||
|
distribution.tables_arrangement_id = existing_arrangement.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it { expect { distribution.save! }.not_to change { TablesArrangement.count } }
|
||||||
|
it { expect { distribution.save! }.to change { Seat.count }.by(1) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#random_distribution' do
|
describe '#random_distribution' do
|
||||||
subject(:distribution) { described_class.new(min_per_table: 5, max_per_table: 10) }
|
subject(:distribution) { described_class.new(min_per_table: 5, max_per_table: 10) }
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user