brewser 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Jon Lochner
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE
@@ -0,0 +1,13 @@
1
+ brewser
2
+ =======
3
+
4
+ Brewser is a ruby library for parsing and generating serialized brewing data
5
+
6
+ Currently brewser is very early in development but will eventually support the following input formats:
7
+ * [BeerXML (v1, v2)](http://beerxml.org)
8
+ * BrewSON - Brewser Recipe and Batches in JSON
9
+ * [ProMash (.rec and text exports)](http://www.promash.com)
10
+
11
+ Input files are deserialized into a common object model for consumption. Brewser supports these output formats:
12
+ * BeerXML
13
+ * BrewSON
@@ -0,0 +1,32 @@
1
+ require 'brewser/ruby-units'
2
+ require 'brewser/json-validation'
3
+ require 'brewser/model/base'
4
+ require 'brewser/exceptions'
5
+ require 'brewser/engines'
6
+
7
+ module Brewser
8
+
9
+ class << self
10
+
11
+ # Returns the potential engine to process the given string
12
+ def identify(string_or_io)
13
+ return BrewSON if BrewSON.acceptable?(string_or_io)
14
+ return BeerXML2 if BeerXML2.acceptable?(string_or_io)
15
+ return BeerXML if BeerXML.acceptable?(string_or_io)
16
+ return ProMashTxt if ProMashTxt.acceptable?(string_or_io)
17
+ return ProMashRec if ProMashRec.acceptable?(string_or_io)
18
+ raise Error, "unable to identify content"
19
+ end
20
+
21
+ # Executes the engine matching the given string
22
+ def parse(string_or_io)
23
+ if engine=self.identify(string_or_io)
24
+ engine.send(:deserialize, string_or_io)
25
+ else
26
+ return nil
27
+ end
28
+ end
29
+
30
+ end
31
+
32
+ end
@@ -0,0 +1,5 @@
1
+ module Brewser
2
+ module BrewSON
3
+
4
+ end
5
+ end
@@ -0,0 +1,18 @@
1
+ module Brewser
2
+
3
+ # Skeleton for an engine
4
+ class Engine
5
+
6
+ def self.acceptable?(q)
7
+ return false
8
+ end
9
+
10
+ def self.deserialize(string_or_io)
11
+ return nil
12
+ end
13
+
14
+ end
15
+
16
+ end
17
+
18
+ Dir.glob(File.dirname(__FILE__) + '/engines/*', &method(:require))
@@ -0,0 +1,433 @@
1
+ class BeerXML < Brewser::Engine
2
+
3
+ require 'nokogiri'
4
+
5
+ class << self
6
+
7
+ # Simple test to see if this looks like XML
8
+ def acceptable?(q)
9
+ Nokogiri::XML(q){|config| config.noblanks }.root ? true : false
10
+ end
11
+
12
+ # Attempt to deserialize the data
13
+ def deserialize(string_or_io)
14
+ begin
15
+ outer = Nokogiri::XML(string_or_io).root
16
+ # We expect to find a plural of one of the models
17
+ objects = (outer>outer.node_name.singularize).map do |inner|
18
+ ("BeerXML::#{inner.node_name.downcase.camelcase}".constantize).from_xml(inner)
19
+ end
20
+ return cleanup(objects)
21
+ # rescue
22
+ # raise Error, "BeerXML engine encountered an issue and can not continue"
23
+ end
24
+ end
25
+
26
+ # Ugly hack to deal with BeerXML oddities
27
+ def cleanup(brewser_objects)
28
+ brewser_objects.each(&:cleanup)
29
+ end
30
+
31
+ end
32
+ end
33
+
34
+ require 'roxml'
35
+
36
+ #
37
+ #
38
+ # These models add the hooks to deserialize the data using ROXML
39
+ # Brought in as a seperate model to allow multiple version of BeerXML
40
+ class BeerXML::Hop < Brewser::Hop
41
+ include ROXML
42
+
43
+ xml_name "HOP"
44
+ xml_convention :upcase
45
+ xml_reader :name
46
+ xml_reader :origin
47
+ xml_reader :description, :from => "NOTES"
48
+
49
+ xml_reader :type
50
+ xml_reader(:form) { |x|
51
+ x="Whole" if x=="Leaf"
52
+ x
53
+ }
54
+
55
+ xml_reader :display_amount
56
+ xml_reader :uncast_amount, :from => "AMOUNT"
57
+
58
+ xml_reader :display_time
59
+ xml_reader :uncast_time, :from => "TIME"
60
+
61
+ xml_reader(:added_when, :from => "USE") { |x|
62
+ x="Dry" if x=="Dry Hop"
63
+ x="FWH" if x=="First Wort"
64
+ x="Boil" if x =="Aroma"
65
+ x
66
+ }
67
+
68
+ xml_reader :alpha_acids, :from => "ALPHA", :as => Float
69
+ xml_reader :beta_acids, :from => "BETA", :as => Float
70
+
71
+ xml_reader :humulene, :as => Float
72
+ xml_reader :caryophyllene, :as => Float
73
+ xml_reader :cohumulone, :as => Float
74
+ xml_reader :myrcene, :as => Float
75
+ xml_reader :farnesene, :as => Float # Not explicitly included in BeerXML
76
+ xml_reader :total_oil, :as => Float # Not explicitly included in BeerXML
77
+ xml_reader :storageability, :from => "HSI", :as => Float
78
+
79
+ xml_reader :substitutes
80
+
81
+ def cleanup
82
+ self.amount = display_amount.present? ? display_amount.u : "#{uncast_amount} kg".u
83
+ self.time = display_time.present? ? display_time.u : "#{uncast_time} min".u
84
+ end
85
+
86
+ end
87
+
88
+ class BeerXML::Fermentable < Brewser::Fermentable
89
+ include ROXML
90
+
91
+ xml_name "FERMENTABLE"
92
+ xml_convention :upcase
93
+ xml_reader :name
94
+ xml_reader :origin
95
+ xml_reader :supplier
96
+ xml_reader :description, :from => "NOTES"
97
+ xml_reader :type
98
+
99
+ xml_reader :display_amount
100
+ xml_reader :uncast_amount, :from => "AMOUNT"
101
+
102
+ xml_reader :yield_percent, :from => "YIELD"
103
+ xml_reader :uncast_potential, :from => "POTENTIAL"
104
+
105
+ xml_reader :color
106
+
107
+ xml_reader :late_addition?, :from => "ADD_AFTER_BOIL"
108
+ xml_reader :recommend_mash?, :from => "RECOMMEND_MASH"
109
+
110
+ xml_reader :max_in_batch, :as => Float
111
+ xml_reader(:diastatic_power) {|x| x.to_f }
112
+ xml_reader(:moisture) {|x| x.to_f }
113
+ xml_reader(:coarse_fine_diff) {|x| x.to_f }
114
+ xml_reader(:protein) {|x| x.to_f }
115
+ xml_reader(:ibu_gal_per_lb) {|x| x.to_f }
116
+
117
+ def cleanup
118
+ self.amount = display_amount.present? ? display_amount.u : "#{uncast_amount} kg".u
119
+ self.potential = uncast_potential.present? ? uncast_potential.to_f : 1+(46*(yield_percent/100))/1000
120
+ self.ppg = (potential-1)*1000
121
+ end
122
+
123
+ end
124
+
125
+ class BeerXML::Additive < Brewser::Additive
126
+ include ROXML
127
+
128
+ xml_name "MISC"
129
+ xml_convention :upcase
130
+ xml_reader :name
131
+ xml_reader :origin
132
+
133
+ xml_reader :type
134
+ xml_reader :form
135
+
136
+ xml_reader :display_amount
137
+ xml_reader :amount_scalar, :from => "AMOUNT"
138
+
139
+ xml_reader :weight?, :from => "AMOUNT_IS_WEIGHT"
140
+
141
+ xml_reader(:added_when, :from => "USE") { |x|
142
+ x="Packaging" if x == "Bottling"
143
+ x
144
+ }
145
+ xml_reader :use_for
146
+
147
+ xml_reader :display_time
148
+ xml_reader :uncast_time, :from => "TIME"
149
+
150
+ xml_reader :description, :from => "NOTES"
151
+
152
+ def set_amount
153
+ return display_amount.u unless display_amount.blank?
154
+ units = weight? ? "kg" : "l"
155
+ return "#{amount_scalar} #{units}".u
156
+ end
157
+
158
+ def cleanup
159
+ self.amount = set_amount
160
+ self.time = display_time.present? ? display_time.u : "#{time} min".u
161
+ end
162
+
163
+ end
164
+
165
+ class BeerXML::Yeast < Brewser::Yeast
166
+ include ROXML
167
+
168
+ xml_name "YEAST"
169
+ xml_convention :upcase
170
+ xml_reader :name
171
+ xml_reader :supplier, :from => "LABORATORY"
172
+ xml_reader :catalog, :from => "PRODUCT_ID"
173
+
174
+ xml_reader :type
175
+ xml_reader :form
176
+
177
+ xml_reader :display_amount
178
+ xml_reader :amount_scalar, :from => "AMOUNT"
179
+ xml_reader :weight?, :from => "AMOUNT_IS_WEIGHT"
180
+
181
+ xml_reader :uncast_min_temperature, :from => "MIN_TEMPERATURE"
182
+ xml_reader :disp_min_temp
183
+
184
+ xml_reader :uncast_max_temperature, :from => "MAX_TEMPERATURE"
185
+ xml_reader :disp_max_temp
186
+
187
+ xml_reader :flocculation
188
+ xml_reader :attenuation, :as => Float
189
+ xml_reader :best_for
190
+ xml_reader :add_to_secondary?, :from => "ADD_TO_SECONDARY"
191
+
192
+ xml_reader :times_cultured
193
+ xml_reader :max_reuse
194
+
195
+ xml_reader :description, :from => "NOTES"
196
+
197
+ def set_amount
198
+ return display_amount.u unless display_amount.blank?
199
+ units = weight? ? "kg" : "l"
200
+ return "#{amount_scalar} #{units}".u
201
+ end
202
+
203
+ def cleanup
204
+ self.amount = set_amount
205
+ self.min_temperature = disp_min_temp.present? ? disp_min_temp.u : "#{uncast_min_temperature} dC".u
206
+ self.max_temperature = disp_max_temp.present? ? disp_max_temp.u : "#{uncast_max_temperature} dC".u
207
+ end
208
+
209
+ end
210
+
211
+ class BeerXML::MashStep < Brewser::MashStep
212
+ include ROXML
213
+
214
+ xml_name "MASH_STEP"
215
+ xml_convention :upcase
216
+ xml_reader :name
217
+ xml_reader :description
218
+
219
+ xml_reader :purpose
220
+ xml_reader(:type) { |x|
221
+ x="Direct" if x=="Temperature"
222
+ x
223
+ }
224
+
225
+ xml_reader(:ramp_time) { |x| "#{x} min".u }
226
+ xml_reader(:rest_time, :from => "STEP_TIME") { |x| "#{x} min".u }
227
+
228
+ xml_reader :uncast_rest_temperature, :from => "STEP_TEMP"
229
+
230
+ xml_reader :water_to_grain_ratio, :from => "WATER_GRAIN_RATIO", :as => Float
231
+
232
+ xml_reader :uncast_infusion_volume, :from => "INFUSE_AMOUNT"
233
+ xml_reader :display_infuse_amt
234
+
235
+ xml_reader :infusion_temperature, :from => "INFUSE_TEMP"
236
+
237
+ property :step_volume, Volume
238
+ property :ramp_time, Time
239
+
240
+ property :rest_temperature, Temperature, :required => true
241
+ property :rest_time, Time, :required => true
242
+
243
+ def cleanup
244
+ self.index = mash_schedule.mash_steps.index(self)+1
245
+ self.infusion_volume = display_infuse_amt.present? ? display_infuse_amt.u : "#{uncast_infusion_volume} l".u unless !uncast_infusion_volume.present? or uncast_infusion_volume == 0
246
+ if infusion_temperature.present?
247
+ self.infusion_temperature
248
+ self.infusion_temperature = infusion_temperature.unitless? ? infusion_temperature*"1 C".u : infusion_temperature
249
+ end
250
+ self.rest_temperature = "#{uncast_rest_temperature} dC".u
251
+ end
252
+
253
+ end
254
+
255
+ class BeerXML::MashSchedule < Brewser::MashSchedule
256
+ include ROXML
257
+
258
+ xml_name "MASH"
259
+ xml_convention :upcase
260
+ xml_reader :name
261
+ xml_reader :description, :from => "NOTES"
262
+
263
+ xml_reader :display_grain_temp
264
+ xml_reader :uncast_grain_temp, :from => "GRAIN_TEMP"
265
+
266
+ xml_reader :display_sparge_temp
267
+ xml_reader :uncast_sparge_temp, :from => "SPARGE_TEMP"
268
+
269
+ xml_attr :mash_steps, :as => [BeerXML::MashStep], :in => "MASH_STEPS"
270
+
271
+ def cleanup
272
+ mash_steps.each do |m|
273
+ m.cleanup
274
+ end
275
+ self.grain_temp = display_grain_temp.present? ? display_grain_temp.u : "#{uncast_grain_temp} dC".u
276
+ self.sparge_temp = display_sparge_temp.present? ? display_sparge_temp.u : "#{uncast_sparge_temp} dC".u
277
+ end
278
+
279
+ end
280
+
281
+ class BeerXML::FermentationStep < Brewser::FermentationStep
282
+
283
+ end
284
+
285
+ class BeerXML::FermentationSchedule < Brewser::FermentationSchedule
286
+
287
+ end
288
+
289
+ class BeerXML::WaterProfile < Brewser::WaterProfile
290
+ include ROXML
291
+
292
+ xml_name "WATER"
293
+ xml_convention :upcase
294
+ xml_reader :name, :from => "NAME"
295
+ xml_reader(:calcium, :from => "CALCIUM") {|x| x.to_f }
296
+ xml_reader(:magnesium, :from => "MAGNESIUM") {|x| x.to_f }
297
+ xml_reader(:sodium, :from => "SODIUM") {|x| x.to_f }
298
+ xml_reader(:chloride, :from => "CHLORIDE") {|x| x.to_f }
299
+ xml_reader(:sulfates, :from => "SULFATE") {|x| x.to_f }
300
+ xml_reader(:bicarbonate, :from => "BICARBONATE") {|x| x.to_f }
301
+ xml_reader(:alkalinity, :from => "ALKALINITY") {|x| x.to_f }
302
+ xml_reader(:ph, :from => "PH") {|x| x.to_f }
303
+ xml_reader :description, :from => "NOTES"
304
+
305
+ def cleanup
306
+ # nothing to do here
307
+ end
308
+ end
309
+
310
+
311
+ class BeerXML::Style < Brewser::Style
312
+ include ROXML
313
+
314
+ xml_name "STYLE"
315
+ xml_convention :upcase
316
+ xml_reader :name
317
+ xml_reader :category
318
+ xml_reader :category_number
319
+ xml_reader :style_letter
320
+ xml_reader :style_guide
321
+ xml_reader(:type) { |x|
322
+ x="Hybrid" if x=="Mixed"
323
+ x
324
+ }
325
+ xml_attr :og_min
326
+ xml_attr :og_max
327
+ xml_attr :fg_min
328
+ xml_attr :fg_max
329
+ xml_attr :ibu_min
330
+ xml_attr :ibu_max
331
+ xml_attr :color_min
332
+ xml_attr :color_max
333
+
334
+ xml_attr :carb_min
335
+ xml_attr :carb_max
336
+ xml_attr :abv_min
337
+ xml_attr :abv_max
338
+
339
+ xml_attr :notes
340
+ xml_attr :profile
341
+ xml_attr :ingredients
342
+ xml_attr :examples
343
+
344
+ def cleanup
345
+ # nothing to do here
346
+ end
347
+ end
348
+
349
+ class BeerXML::Recipe < Brewser::Recipe
350
+ include ROXML
351
+
352
+ xml_name "RECIPE"
353
+ xml_convention :upcase
354
+ xml_reader :date_created, :from => "DATE"
355
+ xml_reader :name
356
+ xml_reader :method, :from => "TYPE"
357
+
358
+ xml_attr :style, :as => BeerXML::Style
359
+ xml_attr :mash_schedule, :as => BeerXML::MashSchedule
360
+ xml_attr :water_profile, :as => BeerXML::WaterProfile, :in => "WATERS"
361
+
362
+ xml_reader :brewer
363
+
364
+ xml_reader :display_batch_size
365
+ xml_reader :uncast_batch_size, :from => "BATCH_SIZE"
366
+
367
+ xml_reader :display_boil_size
368
+ xml_reader :uncast_boil_size, :from => "BOIL_SIZE"
369
+
370
+ xml_reader(:boil_time) { |x| "#{x} min".u }
371
+
372
+ xml_reader :recipe_efficiency, :from => "EFFICIENCY", :as => Float
373
+ xml_reader :carbonation_level, :from => "CARBONATION", :as => Float
374
+
375
+ xml_reader(:estimated_og, :from => "EST_OG") {|x| x.to_f }
376
+ xml_reader(:estimated_fg, :from => "EST_FG") {|x| x.to_f }
377
+ xml_reader(:estimated_color, :from => "EST_COLOR") {|x| x.to_f }
378
+ xml_reader(:estimated_ibu, :from => "IBU") {|x| x.to_f }
379
+ xml_reader(:estimated_abv, :from => "EST_ABV") {|x| x.to_f }
380
+
381
+
382
+ xml_attr :hops, :as => [BeerXML::Hop], :in => "HOPS"
383
+ xml_attr :fermentables, :as => [BeerXML::Fermentable], :in => "FERMENTABLES"
384
+ xml_attr :additives, :as => [BeerXML::Additive], :in => "MISCS"
385
+ xml_attr :yeasts, :as => [BeerXML::Yeast], :in => "YEASTS"
386
+
387
+ xml_reader :description, :from => "NOTES"
388
+
389
+ xml_reader :primary_age, :from => "PRIMARY_AGE"
390
+ xml_reader :display_primary_temp
391
+ xml_reader :uncast_primary_temp, :from => "PRIMARY_TEMP"
392
+
393
+ xml_reader :secondary_age, :from => "SECONDARY_AGE"
394
+ xml_reader :display_secondary_temp
395
+ xml_reader :uncast_secondary_temp, :from => "SECONDARY_TEMP"
396
+
397
+ xml_reader :tertiary_age, :from => "TERTIARY_AGE"
398
+ xml_reader :display_tertiary_temp
399
+ xml_reader :uncast_teritary_temp, :from => "TERTIARY_TEMP"
400
+
401
+ xml_reader :age, :from => "AGE"
402
+ xml_reader :display_temp, :from => "DISPLAY_AGE_TEMP"
403
+ xml_reader :uncast_age_temp, :from => "TEMP"
404
+
405
+ def cleanup
406
+ self.recipe_volume = display_batch_size.present? ? display_batch_size.u : "#{uncast_batch_size} l".u
407
+ self.boil_volume = display_boil_size.present? ? display_boil_size.u : "#{uncast_boil_size} l".u
408
+ self.type = style.type || "Other"
409
+ mash_schedule.cleanup
410
+ hops.each(&:cleanup)
411
+ fermentables.each(&:cleanup)
412
+ additives.each(&:cleanup)
413
+ yeasts.each(&:cleanup)
414
+ current_index = 1
415
+ self.fermentation_schedule = BeerXML::FermentationSchedule.create
416
+ ["primary_age","secondary_age","tertiary_age","age"].each do |stage|
417
+ if self.send(stage).present? && self.send(stage).to_i > 0
418
+ current_step = BeerXML::FermentationStep.new
419
+ current_step.name = stage.split("_")[0].capitalize
420
+ current_step.purpose = current_step.name
421
+ current_step.purpose = "Conditioning" if current_step.purpose == "Age"
422
+ current_step.index = current_index
423
+ current_index += 1
424
+ current_step.time = "#{self.send(stage)} days".u
425
+ display = "display_#{stage.gsub("age","temp")}"
426
+ uncast = "uncast_#{stage.gsub("age","temp")}"
427
+ current_step.temperature = self.send(display).present? ? self.send(display).u : "#{self.send(uncast)} dC".u
428
+ self.fermentation_schedule.fermentation_steps.push current_step
429
+ end
430
+ end
431
+
432
+ end
433
+ end