Comment optimiser les coûts de son abo de train ?

Résumé: comment créer un programme qui nous indique le meilleur abonnement de train annuel basé sur des trajets connus ? Expérience basée sur les CFF suisse, mais le programme est adaptable à tout pays.
Pour les impatients: marche à suivre tout en bas.

Les transports publics coûtent cher et sont compliqués. Abonnement général, abonnement de parcours, abonnement junior, senior, mensuel, annuel, voie 7… et il n’existe pas une solution idéale, tout dépend de l’utilisation que l’on fait des transports publics. Personnellement, j’aime bien voyager le week-end en été en suisse-allemande, en valais, et la semaine je vais au travail en train. Suite à une mission chez un client, j’ai pris goût à l’AG 1ère classe et je me suis dit que celui-ci était optimal vu le nombre de déplacement que je fais. Après-tout, un lausanne-zürich est près de 50CHF, donc fois deux tous les week-end, ça douille…

Oui, sauf que c’est pas ça du tout, comme la suite va nous le démontrer.

Les données sources
Afin de calculer l’abonnement idéal, il faut:
1/ deviner tous les trajets effectués durant l’année
2/ connaître tous les abonnements
3/ connaître les prix des trajets liés à ces abonnements.

Joli exercice pour un ingénieur en informatique:
1/ On peut estimer les trajets à venir dans l’année: dans la moyenne, c’est les mêmes que ceux de l’année précédente (et dans mon cas, il sont consignés dans mon blog photo !)
2/ Les abonnements sont trouvables sur le site des CFF. Je me concentre sur les abonnements de parcours et généraux.
3/ Pour les prix, je peut en une soirée créer un robot qui va interroger le site web des CFF, mais traiter les exceptions, les noms approximatifs, etc… est fastidieux. Autant interroger le site et saisir les prix manuellement.

J’essaie d’être flexible, le programme peut être utile à d’autres (d’où ce post), et je me base sur un seul fichier de données, dont je publie des extraits ci-dessous:

Les Trajets
Dans mon fichier, je met un en-tête entre crocher représentant la donnée qui suit, d’un bloc, terminé par une ligne vide. Et je commence à saisir mes trajets (le premier champ est libre, ici la date).


[TRAJETS]
2.1.13;chavornay;bussigny
2.1.13;morges;chavornay
6.1.13;chavornay;apples
6.1.13;morges;chavornay
7.1.13;chavornay;neuchatel
7.1.13;neuchatel;chavornay
20.1.13;chavornay;lausanne
20.1.13;lausanne;chavornay
27.1.13;chavornay;fribourg
27.1.13;fribourg;chavornay

Lire les trajets est simple:

class Trajets:
	"Trajets is all trajets, counted, for the year"
	def __init__(self, data):
		self.dest = {} # start:{end:nb}
		self.load(data)

	def add(self, start, end, nb):
		while end[-1] in ['\n', '\r']: end = end[:-1]
		if start>end:
			start,end=end,start
		if self.dest.has_key(start):
			self.dest[start][end]=+nb
		else:
			self.dest[start]={end:nb}

	def load(self, data):	
		"Eg: 21.9.13;chavornay;yverdon"
		"Eg: 21.9.13;chavornay;yverdon;12 (12 = nb times)"
		for dataBlock in re.findall("\[TRAJETS.*\n\r?\n", data, flags=re.DOTALL):
			for line in dataBlock.split("\n"):
				spl = line.split(";")
				if len(spl)==3:
					self.add(spl[1].strip(), spl[2].strip(), 1)
				elif len(spl)==4:
					self.add(spl[1].strip(), spl[2].strip(), eval(spl[3])

Tarifs
Pour les tarifs, y’a pas de miracle: il faut chercher sur cff.ch un itinéraire et demande le prix. En abonnement demi-tarif, 1ère et 2ème classe. Et saisir le tout:


[TARIFS]
chavornay;lausanne;6.20;10.90
chavornay;renens;5.30;9.30
chavornay;noiraigue;10.00;17.50
chavornay;geneve;13.00;23.00
chavornay;bern;20;35
chavornay;neuchatel;8.9;15.6
chavornay;fribourg;15.50;27.50
chavornay;villeneuve;11.4;20
chavornay;la sage;27.7;41.7
chavornay;le pont;9.7;17
chavornay;nyon;12.30;21.60

Là aussi le code est simple:

class Tarifs:
	"A Tarif is prices (1st class, 2nd class) for a trajet"
	def __init__(self, data):
		self.tarifs=self.load(data) # {trajetId:(price2eme,price1ere)}

	def getTarif(self, trajetId):
		if self.tarifs.has_key(trajetId):
			return self.tarifs[trajetId]
		print "ids:", self.tarifs.keys()
		raise Exception("Id not found: "+ trajetId.id)

	def load(self, data):
		"Eg: chavornay;lausanne;6.20;10.90"
		tarifs={}
		for tarifDataBlock in re.findall("\[TARIF.*?\n\r?\n", data, flags=re.DOTALL):
			for tarifData in re.findall("(.*);(.*);(.*);(.*)", tarifDataBlock):
				trajetId=TrajetId(tarifData[0], tarifData[1])
				price2eme=eval(tarifData[2])
				price1ere=eval(tarifData[3])
				assert price1ere>price2eme
				tarifs[trajetId]=price2eme,price1ere
		return tarifs

	def checkCompletion(self, destinationTrajetIds):
		missing=[]
		for start, items in destinationTrajetIds.items():
			for end in items.keys():
				trajetId=TrajetId(start, end)
				if TrajetId(start, end) not in tarifs.tarifs:
					missing.add("%s;%s;;"%(start,end))
		if len(missing)>0:
			print "Please complete tarifs:"
			for el in missing:
				print el
			exit(1)

Ceci me permet d’obtenir un dictionnaire dont la clé est un identifiant de Trajet et la valeur est un tuple de prix 2ème classe / 1ère classe.

Petite subtilité, j’utilise une classe pour l’identifiant de trajet:

class TrajetId:
	"The id of a trajet, from [start] to [end]. Rule: startb') or trajetId('a', 'b')"
		if b is None:
			spl = a.split("->")
			if len(spl)!=2: raise Exception("Wrong trajetId:"+a)
			a,b=spl
		if a>b: a,b=b,a
		self.id="%s->%s"%(a.strip(),b.strip())
	def __repr__(self): return "%s"%self.id
	def __str__(self): return "%s"%self.id
	def __hash__(self): return hash(self.id)
	def __eq__(self, other): return type(self)==type(other) and self.id==other.i


Cette classe me permet de stocker un identifiant de trajet d’une source à une cible, mais si je veut créer un identifiant de la cible à la source, j’obtient un identifiant de la source à la cible (inversion des 2 gares). Donc moins de saisies à effectuer dans mes tarifs !

Scénarios
Enfin, il me faut des scénarios, par exemple:
– Abonnement général 2ème classe
– Abonnement de parcours Chavornay-Malley
– …
Quoi de plus adapté que de saisir ce scénario de manière intuitive ? Voici ce que ça donne:

[SCENARIO Abo parcours Malley 1ere]
base_price=150+1790
replacement=chavornay->prilly-malley:0
replacement=chavornay->renens:0
replacement=chavornay->bussigny:0
replacement=renens->prilly-malley:0
replacement=chavornay->chillon:prilly-malley->chillo

[SCENARIO Abo parcours Malley 2eme]
base_price=150+1333
replacement=chavornay->prilly-malley:0
replacement=chavornay->renens:0
replacement=chavornay->bussigny:0
replacement=renens->prilly-malley:0
replacement=chavornay->chillon:prilly-malley->chillon

[SCENARIO AG 1ere]
base_price=5800
replacement=*->*:0

[SCENARIO AG 2eme]
base_price=3550
replacement=*->*:0

Soit je saisit le prix de base, le coût de l’abonnement, mais aussi les trajets de remplacement. Par exemple, faire un trajet de Chavornay à Sion avec un abonnement de parcours de Chavornay à Lausanne revient à devoir prendre un billet de complément de Lausanne à Sion, mais le parcours de Chavornay à Lausanne est de zéro franc.

Voici comment lire un scénario:

class Scenario:
	"A year scenario: basePrice and replacements (some parts could be included in abonnement)"
	def __init__(self, title, basePrice, replacements):
		self.title=title
		self.basePrice=basePrice
		self.replacements=replacements
	def getTitle(self):
		return self.title
	def getBasePrice(self):
		return self.basePrice
	def getTarif(self, tarifs, start, end):
		trajetId = TrajetId(start, end)
		if self.title.find("AG ")>=0:
			return 0 # AG
		if self.replacements.has_key(trajetId):
			replacement = self.replacements[trajetId]
			if replacement=="0" or replacement is None:
				return 0
			if not replacement is None:
				trajetId = replacement
		tarifTuple=tarifs.getTarif(trajetId)
		if self.title.find("1ere")>0:
			return tarifTuple[1]
		return tarifTuple[0]

def readScenarios(data):
	scenarios=[]
	for scenarioData in re.findall("\[SCENARIO.*?\n\r?\n", data, flags=re.DOTALL):
		title = re.findall("\[SCENARIO (.*)]", scenarioData)[0].strip()
		basePrice = eval(re.findall("base_price=(.*)", scenarioData)[0])
		replacements = re.findall("replacement=(.*)", scenarioData)

		# Parse trajetKey1:trajetKey2 for all replacements
		parsedReplacements={}
		for replacement in replacements:
			spl = replacement.split(":")
			trajetId1=TrajetId(spl[0])
			spl1=spl[1]
			if spl1=="0": 
				trajetId2=None
			else:
				trajetId2=TrajetId(spl1)
			parsedReplacements[trajetId1]=trajetId2
		scenarios.append(Scenario(title, basePrice, parsedReplacements))
	return scenarios

Ok, j’ai triché: il y a des valeurs hardcodées, comme le “AG” et le “1ere”, mais bon, je vend pas un programme, ce n’est qu’un article de blog 😉

Programme
Comment goupiller toutes ces pièces ? tout d’abord, il faut lire les données:

data = open("data.txt").read()
scenarios = readScenarios(data)
destinations = Trajets(data)
tarifs = Tarifs(data)
tarifs.checkCompletion(destinations.dest)

Ensuite, pour être certain que tous les remplacements et calculs sont fait correctement, on peut exporter un CSV des données (importable dans Excel):

fout = open("out.csv","w")

# Header
fout.write("start;end;nb;")
for scenario in scenarios:
	fout.write(scenario.getTitle()+";")
fout.write("\n")

# Values
for start,values in destinations.dest.items():
	for end, nb in values.items():
		fout.write("%s;%s;%s;" % (start,end,nb))
		for scenario in scenarios:
			tarif = scenario.getTarif(tarifs, start, end)*nb
			fout.write("%s;" % (tarif))
		fout.write("\n")
fout.close()

Enfin, le noeud du programme. Attention, c’est compliqué 😉 (même pas vrai !)

for scenario in scenarios:
	print scenario.getTitle(),
	somme=scenario.getBasePrice()
	for start,values in destinations.dest.items():
		for end, nb in values.items():
			somme+=scenario.getTarif(tarifs, start, end)*nb
	print somme

Autrement dit, on joue tous les scenarios et on additionne le prix de chaque trajet multiplié par le nombre de trajets.
Et voilà le résultat:


C:\Windows\system32\cmd.exe /c c:\Python27\python.exe cff.py
==================================
Abo parcours Malley 1ere 3440.8
Abo parcours Malley 2eme 2394.9
Abo parcours Lausanne 1ere 4769.2
Abo parcours Lausanne 2eme 2792.7
AG 1ere 5800
AG 2eme 3550
Hit any key to close this window...

Dans mon cas, le calcul est vite fait. Je paie mon AG 1ere au mois, soit 6180CHF/an. ‘Faut être fou quand on y pense. En restant en 1ère et en prenant un abonnement de parcours Chavornay-Malley, j’économise 2739CHF. Et voilà comment on rentabilise 2 courtes soirées de programmation…

Et vous ?
Si vous souhaitez vous aussi chercher votre meilleur abonnement, voici comment procéder:
1/ Charger le programme:
2/ Modifier les données: data.txt
3/ Installer Python (2.7 va très bien: ). Fonctionne sur Mac et Linux. Et l’autre, comment il s’appelle déjà ? ah oui, Windows.
4/ Exécuter le programme avec python (c:\Python27\python.exe cff.py) (ou utiliser idle, un shell ou autre moyen de lancer le fichier python)
5/ Dans la console vous allez voir le résultat comme ci-dessus, et dans le dossier courant, un fichier “out.csv”.
6/ Envoyez-moi un mail pour m’indiquer combien vous avez économisé ! (dutoitc at shibmawa point ch)

le programme pourrait être amélioré pour chercher les tarifs et remplacements automatiquement… mais c’est du travail.

Leave a Reply

Your email address will not be published. Required fields are marked *