organized code into Smr module
[smr.git] / gui / lib / smr_figures.rb
blob8935272febb7863c8b958c9671aeb8a9acb2fa07
2 # This file is part of SMR.
4 # SMR is free software: you can redistribute it and/or modify it under the
5 # terms of the GNU General Public License as published by the Free Software
6 # Foundation, either version 3 of the License, or (at your option) any later
7 # version.
9 # SMR is distributed in the hope that it will be useful, but WITHOUT ANY
10 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
11 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License along with
14 # SMR.  If not, see <http://www.gnu.org/licenses/>.
18 # Implements a mapping mechanism to keep track of the FigureData records of
19 # most recent relevance. Its most useful when inherited from the actual
20 # organization class.
21 class SmrFiguresDataMap
23     def initialize
24         @map = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
25         @last_figure_var = FigureVar.new
26         @last_figure_data = FigureData.new
27     end
29     ##
30     # Add a FigureData to the map, return code indicates whether this data is
31     # already known (1), known and more recent (2) or not known at all (false).
32     #
33     # Map items are categorized into (:name, :year, :id, :period). This is a
34     # multi-dimensional Hash, see
35     # http://stackoverflow.com/questions/10253105/dynamically-creating-a-multi-dimensional-hash-in-ruby
36     #
37     # Unknown items will be map_added to the map. If a FigureData with the same
38     # categorization is passed, the map uses :time to decide whether to update
39     # or ignore it.
40     #
41     def map_add(figure_data)
42         unless (figure_data.is_a?(FigureData) or figure_data.is_a?(SmrAutofigureData))
43             raise ':figure_data must be of FigureData or SmrAutofigureData'
44         end
45         @last_figure_data = figure_data
46         @last_figure_var = @last_figure_data.FigureVar
48         # define locals to shorten the code below
49         name = @last_figure_var.name
50         id = @last_figure_var.id.to_i
51         year = @last_figure_data.time.year.to_i
52         period = @last_figure_data.period
54         if not (@map.has_key?(name) and @map[name].has_key?(year) and @map[name][year].has_key?(id) and @map[name][year][id].has_key?(period)) then
55             # we do not know this figure yet, map_add to map
56             @map[name][year][id][period] = @last_figure_data.time
57             return false
58         else
59             if (@map.has_key?(name) and @map[name].has_key?(year) and @map[name][year].has_key?(id) and @map[name][year][id].has_key?(period)) and @map[name][year][id][period] > figure_data.time then
60                 # known but older
61                 return 1
62             else
63                 # known but more recent => update map
64                 @map[name][year][id][period] = figure_data.time
65                 return 2
66             end
67         end
68     end
70     ##
71     # return name last FigureVar processed by map_add()
72     def get_name
73         @last_figure_var.name
74     end
76     ##
77     # return FigureData id of the last item processed by map_add()
78     def get_figure_data_id
79         @last_figure_data.id
80     end
82     ##
83     # return period of the last item processed by map_add()
84     def get_period
85         @last_figure_data.period
86     end
88     ##
89     # return date as +Time+ of the last item processed by map_add()
90     def get_date
91         @last_figure_data.time
92     end
93 end
96 # Builds FigureVar and FigureData objects into a form that is easy to process
97 # in views.
99 # Use add() to push FigureData records into the table. When done, tell the object
100 # to process all data by calling render(). Thereafter all data is available for
101 # display through the +get_*+ methods.
103 # Note that methods return false or raise an exception when used their proper
104 # mode.
106 class SmrFiguresDataTable < SmrFiguresDataMap
107     def initialize
108         super
110         ##
111         # mode of operation (input or display)
112         #
113         # The add() mgqethod is only active in input mode while all the +get_*+
114         # methods are only available in display mode. You must call render()
115         # before you can get any data out. A new object is in input mode.
116         @input_mode = true
118         # see add() and add_record()
119             @most_recent_figures = Array.new
121         # see add_record()a and update_data()
122         @less_recent_figures = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
124         # the final table created by render()
125         @table = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
127         # see get_class()
128         @class = Array.new
130         ##
131         # CSS class string used for rows, altered by self.cycle_css_row()
132         @cycle_css_row = 'row1'
133     end
135     ##
136     # Process a FigureData while in input mode.
137     def add(figure_data)
138         raise 'add() only works in input mode, it stops working when render() was called' if not @input_mode
140         case self.map_add(figure_data)
141             when false then self.add_record(figure_data)
142             when 1     then self.add_record(figure_data, {:less_recent=>true})
143             when 2     then self.update_data(figure_data)
144         end
146         true
147     end
149     ##
150     # End input mode and render all data ready for displaying
151     #
152     # NOTE: @table is a multi-dimensional array but we use only two dimensions,
153     #       see http://stackoverflow.com/questions/10253105/dynamically-creating-a-multi-dimensional-hash-in-ruby
154     #
155     # Final table will look like when viewed by calling cell() on each
156     # +row+,+col+ coordinate:
157     #
158     #    ______ ________________ ____________ ______________ _______________ _____
159     #   |      |                | -  Year -  | - Quarter 1 -| - Quarter 2 - | ... |
160     #   | year | FigureVar.name | FigureData |  FigureData  |   FigureData  | ... |
161     #   |------|----------------|------------|--------------|---------------|-----|
162     #     ...
163     #
164     # The text_table() method is very useful for debugging.
165     #
166     def render
167         raise 'no data to render(), use add() first' if @most_recent_figures.empty?
168         @input_mode = false
169         @table.clear if @table.count > 0
171         # create table head
172         periods = FigureData.new.get_periods
173         @table[0][0] = ''
174         @table[0][1] = ''
175         periods.each_index {|i| @table[0][i+2]=FigureData.new.translate_period(periods[i])}
177         # add most recent FigureVar
178         # - first: sort by FigureVar.time
179         # - second: add by period index + offset (because of the table head)
180         # ATTENTION: sorting by fd.FigureVar.name probably expensive, consider
181         #            fs.id_figure_var as performant alternative
182         @most_recent_figures.sort_by! { |fd| [fd.time.year, fd.FigureVar.name] }
184         row=1
185         @most_recent_figures.each do |fd|
186             @table[row][0] = fd.time.year
187             @table[row][1] = fd.FigureVar.name
189             col = periods.index(fd.period) + 2
190             @table[row][col] = fd
191     
192             row += 1
193         end
195         # consolidate rows by years and figure name (first two columns)
196         prev_row = @table[1]
197         prev_key = 1
198         @table.each do |k, row|
199             next if k == 0 # skip table head
201             if row[0]==prev_row[0] and row[1]==prev_row[1] then
202                 # merge into prev_row since row is more recent, see sort_by above
203                 @table[k] = prev_row.merge(row)
204                 @table.delete(prev_key) if k > prev_key
205             end
206             prev_row = @table[k]
207             prev_key = k
208         end
210         # re-index @table since rows(), columns() and cell() rely on
211         # consecutive numbering
212         tmp_table = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
213         i=0
214         @table.each_key do |k|
215             tmp_table[i] = @table[k]
216             i += 1
217         end
218         @table = tmp_table
219     end
221     ##
222     # print table in text format (for debugging only!)
223     def text_table
224         self.render
225         format = Array.new(self.columns, '%12s').join('|')
227         puts '----------- @table ------------'
228         for r in 0...self.rows
229             row = Array.new
230             for c in 0...self.columns
231                 row[c] = self.cell(r, c)
232             end
233             puts(format % row)
234         end
235         puts '-------------------------------'
236     end
238     ##
239     # Returns number of rows after render().
240     def rows
241         raise 'rows() only works after render() was called' if @input_mode
242         @table.count
243     end
245     ##
246     # Returns number of columns after render().
247     # NOTE: the first row sets the number of columns of the table
248     def columns
249         raise 'columns() only works after render() was called' if @input_mode
250         @table[0].count
251     end
253     ##
254     # Returns content for a cell identified by row and col, nil if empty.
255     def cell(row, col)
256         raise 'cell() only works after render() was called' if @input_mode
258         if c = self.raw_cell_content(row, col) then
259             if c.is_a?(FigureData) or c.is_a?(SmrAutofigureData)
260                 c.value
261             else
262                 # leftmost column is always a descriptive string, not to be
263                 # rounded, scaled, whatever ...
264                 if col==0 then c.to_s else c end
265             end
266         else
267             nil
268         end
269     end
271     ##
272     # Returns unit of value in cell or nil if there is nothing.
273     def cell_unit(row, col)
274         raise 'cell_unit() only works after render() was called' if @input_mode
276         if c = self.raw_cell_content(row, col) then
277             if c.is_a?(FigureData) or c.is_a?(SmrAutofigureData)
278                 c.FigureVar.unit
279             end
280         end
281     end
283     ##
284     # Return rowspanning information for given cell (row, col) or false if this
285     # cell should not span at all
286     #
287     # rowspanning information for @datatable is stored in @rowspan and looks
288     # this way:
289     # - two-dimensional array: #row, #col
290     # - a number indicates how many lines a cell should span down
291     # - a dash ('-') indicates that a cell is covered by another cell that
292     #   spans above it
293     # - #row,#col coordinates are not set means the cell is not affected by
294     #   rowspanning at all
295     def rowspan(row, col)
296         raise 'rowspan() only works after render() was called' if @input_mode
297         false
298     end
300     ##
301     # Return class information for given row or cell
302     #
303     # False is returned if there is no such information (the row or cell should
304     # not have any class attribute set).
305     #
306     # The +col+ paramenter is optional, if given you ask for a specific cell,
307     # if ommited (default) you ask for the entire row.
308     #
309     # CSS class information for @datatable is stored in @class and looks like
310     # this:
311     # - one-or-two-dimensional array: #row, #col
312     # - contains a string of CSS class names that should be set on a given row
313     #   and/or cell
314     # - false means that there shouldn't be any class attribute set
315     def class(row, col=false)
316         raise 'class() only works after render() was called' if @input_mode
317         false
318     end
320     ##
321     # Return link to edit FigureVar of given field
322     #
323     # False is returned if that field is empty.
324     def link(row, col)
325         raise 'link() only works after render() was called' if @input_mode
326         
327         if c = self.raw_cell_content(row, col) then
328            if c.is_a?(FigureData) then return('/figures/%i/edit' % c.id) end
329         end
330         
331         false
332     end
334 protected
336     ##
337     # add FigureData to internal @most_recent_figures or @less_recent_figures
338     #
339     # @less_recent_figures contains all those where a more recent counterpart
340     # is available in @most_recent_figures. See get_summary() and
341     # have_summary().
342     #
343     # @most_recent_figures is a regular +Array+
344     #
345     # @less_recent_figures is a multi-dimensional +Hash+ structured as
346     # 
347     #   @less_recent_figures[:year][:period][:id_figure_data] = Array.new
348     #
349     def add_record(figure_data, options={ :less_recent=>false })
350         y  = figure_data.time.year
351         p  = figure_data.period
352         id = figure_data.id
354         if options[:less_recent] then
355             if @less_recent_figures.has_key?(y) and @less_recent_figures[y].has_key?(p) and @less_recent_figures[y][p].has_key?(id) then
356                @less_recent_figures[y][p][id] << figure_data
357             else
358                 @less_recent_figures[y][p][id] = [figure_data]
359             end
360         else
361             @most_recent_figures << figure_data
362         end
364         true
365     end
367     ##
368     # update FigureData item in @most_recent_figures, make sure the old data is
369     # kept in @less_recent_figures.
370     # FIXME: to be implemented!
371     def update_data(figure_data)
372         #@most_recent_figures.find(:id_figure_var=>figure_data.id_figure_var, ...)
373         true
374     end
376     ##
377     # Returns content for a cell identified by row and col.
378     #
379     # The content can be +nil+ if that cell is empty or whatever is there, ie.
380     # a FigureData, a +String+, etc....
381     def raw_cell_content(row, col)
382         if @table.has_key?(row) and @table[row].has_key?(col) then
383                 @table[row][col]
384         else nil end
385     end
389 # creates many (!) SmrAutofigureData objects from a FigureVar record
391 # A SmrAutofigure is based on a FigureVar record with the :expression field
392 # containing a math expression. That is each FigureVar becomes a SmrAutofigure
393 # by setting the :expression field. It will return to being a ordinary
394 # FigureVar by emptying the the :expression field.
396 # Each SmrAutofigure will create as many SmrAutofigureData objects as possible,
397 # depending on the information available to solve the given :expression. It
398 # might be hundreds if there is input data for, say, 100 fiscal quarters.
400 class SmrAutofigure
402     ##
403     # Init with FigureVar and a list of variables contained in its :expression.
404     # See SmrDaemonClient#parse_math_expressions.
405     def initialize(figure_var, variables)
406         raise 'figure_var must be a FigureVar object' unless figure_var.is_a?(FigureVar)
407         raise 'figure_var must have :expression field set' if figure_var.expression.empty?
408         @autofigure = figure_var
409         @expression_vars = Hash.new
411         # internally we work with FigureVar ids, not with their names
412         FigureVar.select(:id, :name).where(:name=>variables).each do |v|
413             @expression_vars[v.id]=v.name
414         end
416         # collects FigureData by year and period, to know what we have (=>
417         # #add()) and still need (=> #get_missing_variables)
418         @datamatrix = Hash.new
420         # collects Time if most recent FigureData per row in @datamatrix,
421         # necessary to know how to time the SmrAutofigureData objects
422         @timematrix = Hash.new
424         @id_stock = nil
425     end
427     ##
428     # Add FigureData as input for solving the expression.
429     #
430     # Returns +true+ if it was added or +false+ if it did not help to fill this
431     # expression.
432     #
433     # Note: data is overwritten. A FigureData of same +:year+ and +:period+
434     # will overwrite the previously passed one. So mind the order.
435     def add(figure_data)
436         raise 'figure_data must be a FigureData object' unless figure_data.is_a?(FigureData)
438         if @id_stock and @id_stock != figure_data.id_stock
439             raise 'add()ing FigureData of another Stock should not happen, should it?'
440         else
441             @id_stock = figure_data.id_stock
442         end
444         i = '%s_%s' % [figure_data.time.year, figure_data.period ]
445         if @expression_vars.keys.include?(figure_data.id_figure_var)
446             newdata = { figure_data.id_figure_var=>figure_data.value }
447             @datamatrix[i] = if @datamatrix[i] then @datamatrix[i].merge(newdata) else newdata end
449             if not @timematrix[i] or @timematrix[i] < figure_data.time then
450                 @timematrix[i] = figure_data.time
451             end
453             return true
454         end
456         return false
457     end
459     ##
460     # Tells whats missing to solve all expressions.
461     def get_missing_variables
462         missing = Array.new
463         @datamatrix.each do |i,d|
464             diff = @expression_vars.keys - d.keys
465             if not diff.empty?
466                 missing << i.to_s + '_' + @expression_vars.select{|k,v| diff.include?(k) }.values.join('-')
467             end
468         end
469         missing
470     end
472     ##
473     # Returns collection of SmrAutofigureData objects with solvable
474     # expressions. Objects with non-solvable expressions (missing data) are
475     # skipped silently.
476     def get
477         @datamatrix.collect do |i,d|
478             diff = @expression_vars.keys - d.keys
479             if diff.empty?
480                 dn = Hash.new
481                 d.each{ |k,v|  dn[@expression_vars[k]]=v }
482                 SmrAutofigureData.new(@autofigure, dn, @id_stock, i.split('_').second, @timematrix[i])
483             end
484         end.compact
485     end
490 # like a FigureData object without related database record
492 # A SmrAutofigureData object is ment to behave exactly like a FigureData
493 # object. Except that it can`t be saved or updated or trigger any other
494 # database operation. Its an entirely artificial record so to say.
495 class SmrAutofigureData
496     # compatibility with FigureData, always +nil+
497     attr_reader :id
499     # compatibility with FigureData, empty
500     attr_reader :analyst, :is_expected, :is_audited, :comment
502     # where this autofigure was derived from
503     attr_reader :id_figure_var, :id_stock, :period
505     # unix timestamp when this autofigure was made, also see time()
506     attr_reader :date
508     # the Hash of data variables and values necessary for solving
509     attr_reader :expression, :solving_data
511     # result of the expression or +nil+ if not yet solved
512     # Note: this must be calculated/set externaly
513     attr_accessor :value
515     ##
516     # Give FigureVar out of which this SmrAutofigureData is derived and a Hash
517     # of var=>value for solving the expression. +id_stock+ is to know what this
518     # is for.
519     def initialize(autofigure, values, id_stock, period, time)
520         raise 'autofigure must be a FigureVar' unless autofigure.is_a?(FigureVar)
521         raise 'values must be a Hash' unless values.is_a?(Hash)
522         raise 'time must be of Time' unless time.is_a?(Time)
523         @autofigure = autofigure
524         @expression = autofigure.expression
525         @solving_data = values
527         # carry on meaningful fields
528         @id_figure_var = autofigure.id
529         @id_stock = id_stock
530         @date = time.to_i
531         @period = period
532         @analyst = self.class
533     end
535     ##
536     # Time when this autofigure was made
537     def time
538         Time.at(self.date)
539     end
541     ##
542     # wrapper for compatibility with FigureData
543     def get_periods
544         FigureData.new.get_periods
545     end
547     ##
548     # wrapper for compatibility with FigureData
549     def translate_period(period=self.period)
550         FigureData.new.translate_period(period)
551     end
553     ##
554     # wrapper for compatibility with FigureData
555     def FigureVar
556         @autofigure
557     end