lunes, 4 de noviembre de 2013

El algoritmo de la compra del billete de tren

No es el título de un capítulo de The big bang theory, sino el dilema al que me enfrento al comprar un billete de tren. LLamadme raro, pero yo necesito un algoritmo para comprar mi abono mensual de renfe para ir a trabajar. Y no es que vuelva loca a la amable taquillera de mi estación como haría  Sheldon Cooper, por mucho que cierta persona se empeñe en que a veces le recuerdo a él, entre otras cosas porque soy más del estilo de Roy Trenneman. La razón es que el abono de renfe incluye un viaje de ida y vuelta al día durante un mes. Al cabo de un mes te caduca el abono independientemente del número de viajes que hayas hecho. Es que yo no trabajo todos los días, señor directivo de renfe, alego. Todo lo que empieza por es que es una excusa, excepto esqueleto y esquema, aduce el señor directivo. ¿Y esqueje?
He aquí nuestro problema, la abolición de la esclavitud. Si trabajásemos todos los días no tendríamos que andar contando cuántos días laborables incluye nuestro abono, aunque tal como está evolucionando la legislación laboral es probable que en un futuro no necesitemos hacerlo. La idea es minimizar el precio diario del billete retrasando la compra del abono para incluir el mayor número de días laborables con la dificultad añadida de que los días que la retrasemos también tenemos que ir a trabajar y por lo tanto también debemos pagar el billete sencillo que es mucho más caro. Explicándolo de una forma sencilla, tenemos que averiguar cuántos billetes sencillos compensan la inclusión de unos días más en el abono. Para ello nos podemos basar en dos criterios, aunque ambos suelen coincidir: el precio por día laborable y el precio por día real. El primero se calcularía dividiendo el número de días laborables que hemos ido a trabajar por el precio que hemos pagado. El segundo son los días que han pasado (incluidos festivos y vacaciones) desde que empezamos a contar dividido por el coste de los billetes. Para ello he desarrollado un script en Ruby. El resultado del script es el cálculo del precio si comprásemos el abono durante varios días teniendo en cuenta los billetes sencillos que hemos comprado previamente. Como hoy tocaba comprar el billete, ejecuto el script y este es el resultado:

./abono.rb --initial_date 20131104 --simple_ticket_price 5.70 --subscription_price 77.90 --months 1 --no-sunday --no-saturday --calculate_days 30

Condiciones:
Fecha inicial: 2013-11-04
Fecha final: 2013-12-03
Días de cálculo: 30
Precio del billete sencillo: 5.7
Precio del abono: 77.9
Trabaja los lunes: true
Trabaja los martes: true
Trabaja los miércoles: true
Trabaja los jueves: true
Trabaja los viernes: true
Trabaja los sábados: false
Trabaja los domingos: false
**2013-11-04: TOTAL PAGADO: 77.9. 22 DíAS EFECTIVOS. DíAS SIN BONO: 0. PRECIO POR DíA LABORABLE: 3.54. DíAS REALES: 30. PRECIO POR DíA REAL: 2.6
2013-11-05: Total pagado: 83.6. 22 días efectivos. Días sin bono: 1. Precio por día laborable: 3.63. Días reales: 31. Precio por día real: 2.7
2013-11-06: Total pagado: 89.3. 22 días efectivos. Días sin bono: 2. Precio por día laborable: 3.72. Días reales: 32. Precio por día real: 2.79
2013-11-07: Total pagado: 95.0. 22 días efectivos. Días sin bono: 3. Precio por día laborable: 3.8. Días reales: 33. Precio por día real: 2.88
2013-11-08: Total pagado: 100.7. 21 días efectivos. Días sin bono: 4. Precio por día laborable: 4.03. Días reales: 34. Precio por día real: 2.96
--2013-11-09: Total pagado: 106.4. 20 días efectivos. Días sin bono: 5. Precio por día laborable: 4.26. Días reales: 35. Precio por día real: 3.04
--2013-11-10: Total pagado: 106.4. 21 días efectivos. Días sin bono: 5. Precio por día laborable: 4.09. Días reales: 36. Precio por día real: 2.96
2013-11-11: Total pagado: 106.4. 22 días efectivos. Días sin bono: 5. Precio por día laborable: 3.94. Días reales: 37. Precio por día real: 2.88
2013-11-12: Total pagado: 112.1. 22 días efectivos. Días sin bono: 6. Precio por día laborable: 4.0. Días reales: 38. Precio por día real: 2.95
2013-11-13: Total pagado: 117.8. 22 días efectivos. Días sin bono: 7. Precio por día laborable: 4.06. Días reales: 39. Precio por día real: 3.02
2013-11-14: Total pagado: 123.5. 22 días efectivos. Días sin bono: 8. Precio por día laborable: 4.12. Días reales: 40. Precio por día real: 3.09
2013-11-15: Total pagado: 129.2. 21 días efectivos. Días sin bono: 9. Precio por día laborable: 4.31. Días reales: 41. Precio por día real: 3.15
--2013-11-16: Total pagado: 134.9. 20 días efectivos. Días sin bono: 10. Precio por día laborable: 4.5. Días reales: 42. Precio por día real: 3.21
--2013-11-17: Total pagado: 134.9. 21 días efectivos. Días sin bono: 10. Precio por día laborable: 4.35. Días reales: 43. Precio por día real: 3.14
2013-11-18: Total pagado: 134.9. 22 días efectivos. Días sin bono: 10. Precio por día laborable: 4.22. Días reales: 44. Precio por día real: 3.07
2013-11-19: Total pagado: 140.6. 22 días efectivos. Días sin bono: 11. Precio por día laborable: 4.26. Días reales: 45. Precio por día real: 3.12
2013-11-20: Total pagado: 146.3. 22 días efectivos. Días sin bono: 12. Precio por día laborable: 4.3. Días reales: 46. Precio por día real: 3.18
2013-11-21: Total pagado: 152.0. 22 días efectivos. Días sin bono: 13. Precio por día laborable: 4.34. Días reales: 47. Precio por día real: 3.23
2013-11-22: Total pagado: 157.7. 21 días efectivos. Días sin bono: 14. Precio por día laborable: 4.51. Días reales: 48. Precio por día real: 3.29
--2013-11-23: Total pagado: 163.4. 20 días efectivos. Días sin bono: 15. Precio por día laborable: 4.67. Días reales: 49. Precio por día real: 3.33
--2013-11-24: Total pagado: 163.4. 21 días efectivos. Días sin bono: 15. Precio por día laborable: 4.54. Días reales: 50. Precio por día real: 3.27
2013-11-25: Total pagado: 163.4. 22 días efectivos. Días sin bono: 15. Precio por día laborable: 4.42. Días reales: 51. Precio por día real: 3.2
2013-11-26: Total pagado: 169.1. 22 días efectivos. Días sin bono: 16. Precio por día laborable: 4.45. Días reales: 52. Precio por día real: 3.25
2013-11-27: Total pagado: 174.8. 22 días efectivos. Días sin bono: 17. Precio por día laborable: 4.48. Días reales: 53. Precio por día real: 3.3
2013-11-28: Total pagado: 180.5. 22 días efectivos. Días sin bono: 18. Precio por día laborable: 4.51. Días reales: 54. Precio por día real: 3.34
2013-11-29: Total pagado: 186.2. 21 días efectivos. Días sin bono: 19. Precio por día laborable: 4.65. Días reales: 55. Precio por día real: 3.39
--2013-11-30: Total pagado: 191.9. 20 días efectivos. Días sin bono: 20. Precio por día laborable: 4.8. Días reales: 56. Precio por día real: 3.43
--2013-12-01: Total pagado: 191.9. 21 días efectivos. Días sin bono: 20. Precio por día laborable: 4.68. Días reales: 57. Precio por día real: 3.37
2013-12-02: Total pagado: 191.9. 22 días efectivos. Días sin bono: 20. Precio por día laborable: 4.57. Días reales: 58. Precio por día real: 3.31
2013-12-03: Total pagado: 197.6. 22 días efectivos. Días sin bono: 21. Precio por día laborable: 4.6. Días reales: 59. Precio por día real: 3.35
2013-12-04: Total pagado: 203.3. 22 días efectivos. Días sin bono: 22. Precio por día laborable: 4.62. Días reales: 60. Precio por día real: 3.39


El script devuelve los días festivos precedidos de "--", el mejor precio por día laborable precedido de "**" y el mejor precio por día real precedido de "$$". Como noviembre es un mes sin festivos ni puentes nos conviene comprar nuestro abono el siguiente día laborable después de su caducidad. El problema es el siguiente abono que incluye varios días festivos y vacaciones.

./abono.rb --initial_date 20131204 --simple_ticket_price 5.70 --subscription_price 77.90 --months 1 --calculate_days 30 --no-sunday --no-saturday --holiday 20131225 --holiday 20140101 --holiday_start 20131215 --holiday_end 20131225
Condiciones:
Fecha inicial: 2013-12-04
Fecha final: 2014-01-03
Días de cálculo: 30
Precio del billete sencillo: 5.7
Precio del abono: 77.9
Trabaja los lunes: true
Trabaja los martes: true
Trabaja los miércoles: true
Trabaja los jueves: true
Trabaja los viernes: true
Trabaja los sábados: false
Trabaja los domingos: false
Festivo: 2013-12-25
Festivo: 2014-01-01
Vacaciones: 2013-12-15 a 2013-12-25
2013-12-04: Total pagado: 77.9. 14 días efectivos. Días sin bono: 0. Precio por día laborable: 5.56. Días reales: 31. Precio por día real: 2.51
2013-12-05: Total pagado: 83.6. 13 días efectivos. Días sin bono: 1. Precio por día laborable: 5.97. Días reales: 32. Precio por día real: 2.61
2013-12-06: Total pagado: 89.3. 12 días efectivos. Días sin bono: 2. Precio por día laborable: 6.38. Días reales: 33. Precio por día real: 2.71
--2013-12-07: Total pagado: 95.0. 12 días efectivos. Días sin bono: 3. Precio por día laborable: 6.33. Días reales: 34. Precio por día real: 2.79
--2013-12-08: Total pagado: 95.0. 13 días efectivos. Días sin bono: 3. Precio por día laborable: 5.94. Días reales: 35. Precio por día real: 2.71
2013-12-09: Total pagado: 95.0. 14 días efectivos. Días sin bono: 3. Precio por día laborable: 5.59. Días reales: 36. Precio por día real: 2.64
2013-12-10: Total pagado: 100.7. 14 días efectivos. Días sin bono: 4. Precio por día laborable: 5.59. Días reales: 37. Precio por día real: 2.72
2013-12-11: Total pagado: 106.4. 14 días efectivos. Días sin bono: 5. Precio por día laborable: 5.6. Días reales: 38. Precio por día real: 2.8
2013-12-12: Total pagado: 112.1. 13 días efectivos. Días sin bono: 6. Precio por día laborable: 5.9. Días reales: 39. Precio por día real: 2.87
2013-12-13: Total pagado: 117.8. 12 días efectivos. Días sin bono: 7. Precio por día laborable: 6.2. Días reales: 40. Precio por día real: 2.95
--2013-12-14: Total pagado: 123.5. 12 días efectivos. Días sin bono: 8. Precio por día laborable: 6.18. Días reales: 41. Precio por día real: 3.01
--2013-12-15: Total pagado: 123.5. 13 días efectivos. Días sin bono: 8. Precio por día laborable: 5.88. Días reales: 42. Precio por día real: 2.94
--2013-12-16: Total pagado: 123.5. 14 días efectivos. Días sin bono: 8. Precio por día laborable: 5.61. Días reales: 43. Precio por día real: 2.87
--2013-12-17: Total pagado: 123.5. 15 días efectivos. Días sin bono: 8. Precio por día laborable: 5.37. Días reales: 44. Precio por día real: 2.81
--2013-12-18: Total pagado: 123.5. 16 días efectivos. Días sin bono: 8. Precio por día laborable: 5.15. Días reales: 45. Precio por día real: 2.74
--2013-12-19: Total pagado: 123.5. 16 días efectivos. Días sin bono: 8. Precio por día laborable: 5.15. Días reales: 46. Precio por día real: 2.68
--2013-12-20: Total pagado: 123.5. 16 días efectivos. Días sin bono: 8. Precio por día laborable: 5.15. Días reales: 47. Precio por día real: 2.63
--2013-12-21: Total pagado: 123.5. 17 días efectivos. Días sin bono: 8. Precio por día laborable: 4.94. Días reales: 48. Precio por día real: 2.57
--2013-12-22: Total pagado: 123.5. 18 días efectivos. Días sin bono: 8. Precio por día laborable: 4.75. Días reales: 49. Precio por día real: 2.52
--2013-12-23: Total pagado: 123.5. 19 días efectivos. Días sin bono: 8. Precio por día laborable: 4.57. Días reales: 50. Precio por día real: 2.47
--2013-12-24: Total pagado: 123.5. 20 días efectivos. Días sin bono: 8. Precio por día laborable: 4.41. Días reales: 51. Precio por día real: 2.42
--2013-12-25: Total pagado: 123.5. 21 días efectivos. Días sin bono: 8. Precio por día laborable: 4.26. Días reales: 52. Precio por día real: 2.38
$$2013-12-26: TOTAL PAGADO: 123.5. 21 DíAS EFECTIVOS. DíAS SIN BONO: 8. PRECIO POR DíA LABORABLE: 4.26. DíAS REALES: 53. PRECIO POR DíA REAL: 2.33
2013-12-27: Total pagado: 129.2. 20 días efectivos. Días sin bono: 9. Precio por día laborable: 4.46. Días reales: 54. Precio por día real: 2.39
--2013-12-28: Total pagado: 134.9. 20 días efectivos. Días sin bono: 10. Precio por día laborable: 4.5. Días reales: 55. Precio por día real: 2.45
--2013-12-29: Total pagado: 134.9. 21 días efectivos. Días sin bono: 10. Precio por día laborable: 4.35. Días reales: 56. Precio por día real: 2.41
**2013-12-30: TOTAL PAGADO: 134.9. 22 DíAS EFECTIVOS. DíAS SIN BONO: 10. PRECIO POR DíA LABORABLE: 4.22. DíAS REALES: 57. PRECIO POR DíA REAL: 2.37
2013-12-31: Total pagado: 140.6. 22 días efectivos. Días sin bono: 11. Precio por día laborable: 4.26. Días reales: 58. Precio por día real: 2.42
--2014-01-01: Total pagado: 146.3. 22 días efectivos. Días sin bono: 12. Precio por día laborable: 4.3. Días reales: 59. Precio por día real: 2.48
2014-01-02: Total pagado: 146.3. 22 días efectivos. Días sin bono: 12. Precio por día laborable: 4.3. Días reales: 60. Precio por día real: 2.44
2014-01-03: Total pagado: 152.0. 21 días efectivos. Días sin bono: 13. Precio por día laborable: 4.47. Días reales: 61. Precio por día real: 2.49

El resultado es algo sorprendente, ya que si elegimos bien el día de compra nos puede salir más barato el transporte cuando hay festivos y vacaciones, ya que cubrimos el mayor número de días al menor precio. El código fuente del script es este:

#!/usr/bin/ruby
#encoding: utf-8

require 'optparse'
require 'date'
#require 'colorize'

def working_day(f,holidays,week_days,holiday_periods)
holiday=false
holidays.each do |h|
if h==f
holiday=true
end
end
holiday_periods.each do |k,v|
if f>=k && f<=v
holiday=true
end
end
return !holiday && week_days[f.wday]
end

initial_date=Date.today
simple_ticket_price=5.4
subscription_price=77.8
week_days=Array.new(7,true)
holidays=Array.new
holiday_periods=Hash.new
final_date=initial_date>>1
calculate_days=10
holiday_period_start=Date.new

opt_parser = OptionParser.new do |opts|

opts.banner="Uso abono.rb [options]"
opts.separator ""
opts.on("--initial_date Fecha inicial", String , :required, "Fecha de inicio del bono") do |switch|
initial_date=Date.iso8601(switch)
final_date=(initial_date>>1)-1
end
opts.on("--simple_ticket_price Precio", Float , :required, "Precio del billete sencillo") do |switch|
simple_ticket_price=switch
end
opts.on("--subscription_price Precio", Float , :required, "Precio del abono") do |switch|
subscription_price=switch
end
opts.on("--months Duración", Integer , :required, "Duración del abono en meses") do |switch|
final_date=(initial_date>>switch)-1
end
opts.on("--days Duración", Integer , :required, "Duración del abono en días") do |switch|
final_date=initial_date+switch-1
end
opts.on("--years Duración", Integer , :required, "Duración del abono en años") do |switch|
final_date=initial_date.next_year(switch)-1
end
opts.on("--calculate_days Duración", Integer , :required, "Días de cálculo") do |switch|
calculate_days=switch
end
opts.on("--no-sunday", :none, "No trabaja los domingos") do |switch|
week_days[0]=false
end
opts.on("--no-monday", :none, "No trabaja los lunes") do |switch|
week_days[1]=false
end
opts.on("--no-tuesday", :none, "No trabaja los martes") do |switch|
week_days[2]=false
end
opts.on("--no-wednesday", :none, "No trabaja los miércoles") do |switch|
week_days[3]=false
end
opts.on("--no-thursday", :none, "No trabaja los jueves") do |switch|
week_days[4]=false
end
opts.on("--no-friday", :none, "No trabaja los viernes") do |switch|
week_days[5]=false
end
opts.on("--no-saturday", :none, "No trabaja los sábados") do |switch|
week_days[6]=false
end
opts.on("--holiday Fecha", String, :required, "Fecha de un día festivo") do |switch|
holidays.push Date.iso8601(switch)
end
opts.on("--holiday_start Fecha", String, :required, "Fecha de inicio de las vacaciones") do |switch|
holiday_period_start=Date.iso8601(switch)
end
opts.on("--holiday_end Fecha", String, :required, "Fecha de final de las vacaciones") do |switch|
holiday_periods[holiday_period_start]=Date.iso8601(switch)
end
end

opt_parser.parse!
puts "Condiciones: "
puts "Fecha inicial: #{initial_date}"
puts "Fecha final: #{final_date}"
puts "Días de cálculo: #{calculate_days}"
puts "Precio del billete sencillo: #{simple_ticket_price}"
puts "Precio del abono: #{subscription_price}"
puts "Trabaja los lunes: #{week_days[1]}"
puts "Trabaja los martes: #{week_days[2]}"
puts "Trabaja los miércoles: #{week_days[3]}"
puts "Trabaja los jueves: #{week_days[4]}"
puts "Trabaja los viernes: #{week_days[5]}"
puts "Trabaja los sábados: #{week_days[6]}"
puts "Trabaja los domingos: #{week_days[0]}"
holidays.each do |holiday|
puts "Festivo: #{holiday}"
end
holiday_periods.each do |k,v|
puts "Vacaciones: #{k} a #{v}"
end

result=Hash.new
min_price=1.0/0.0 #Sí amigos, ruby sabe que 1/0 es infinito
min_real_price=1.0/0.0

min_price_string=String.new
min_real_price_string=String.new

0.upto(calculate_days) do |i|
effective_days_count=0
no_subscription_days_count=0
global_price=subscription_price
if i>0
(initial_date..initial_date+(i-1)).each do |f|
if working_day(f,holidays,week_days,holiday_periods)
global_price+=simple_ticket_price
no_subscription_days_count+=1
end
end
end
(initial_date+i..final_date+i).each do |f|
if working_day(f,holidays,week_days,holiday_periods)
effective_days_count+=1
end
end
price_per_day=(global_price/(effective_days_count+no_subscription_days_count)).round(2)
real_price_per_day=(global_price/((final_date+i)-initial_date)).round(2)
if min_price>price_per_day
min_price=price_per_day
min_price_string="#{initial_date+i}: Total pagado: #{global_price.round(2)}. #{effective_days_count} días efectivos. Días sin bono: #{no_subscription_days_count}. Precio por día laborable: #{price_per_day}. Días reales: #{((final_date+i)-initial_date).to_i()+1}. Precio por día real: #{(global_price/(((final_date+i)-initial_date)+1)).round(2)}"
end
if min_real_price>real_price_per_day
min_real_price=real_price_per_day
min_real_price_string="#{initial_date+i}: Total pagado: #{global_price.round(2)}. #{effective_days_count} días efectivos. Días sin bono: #{no_subscription_days_count}. Precio por día laborable: #{price_per_day}. Días reales: #{((final_date+i)-initial_date).to_i()+1}. Precio por día real: #{(global_price/(((final_date+i)-initial_date)+1)).round(2)}"
end
result["#{initial_date+i}: Total pagado: #{global_price.round(2)}. #{effective_days_count} días efectivos. Días sin bono: #{no_subscription_days_count}. Precio por día laborable: #{price_per_day}. Días reales: #{((final_date+i)-initial_date).to_i()+1}. Precio por día real: #{(global_price/(((final_date+i)-initial_date)+1)).round(2)}"]=price_per_day
end

result.each do |k,v|
if k==min_price_string
puts "**#{k.upcase}"
elsif k==min_real_price_string
puts "$$#{k.upcase}"
elsif !working_day(Date.iso8601(k[0,10]),holidays,week_days,holiday_periods)
puts "--#{k}"
else
puts k
end
end


Las opciones del script son las siguientes:

--initial_date Fecha desde la que queremos calcular la compra
--simple_ticket_price Precio del billete sencillo

--subscription_price Precio del abono mensual
--days Duración del abono en días

--months Duración del abono en meses

--years Duración del abono en años

--calculate_days Número de días que queremos calcular

--no-sunday No trabaja los domingos

--no-monday No trabaja los lunes

--no-tuesday No trabaja los martes

--no-wednesday No trabaja los miércoles

--no-thursday No trabaja los jueves

--no-friday No trabaja los viernes

--no-saturday No trabaja los sábados

--holiday Fecha de un día festivo

--holiday_start Fecha de inicio de vacaciones

--holiday_end Fecha de fin de vacaciones