minor features, layout improvements, many fixes
[smr.git] / gui / lib / smr / figures.rb
blob3dd367295b18675a6bf340d020f9495bbecf12e7
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/>.
16 require 'math_engine'
17   
18 module Smr  #:nodoc:
19   ##
20   # Implements a mapping mechanism to keep track of the FigureData records of
21   # most recent relevance. Its most useful when inherited from the actual
22   # organization class.
23   class FiguresDataMap
24   
25       def initialize
26           @map = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
27           @last_figure_var = FigureVar.new
28           @last_figure_data = FigureData.new
29       end
30   
31       ##
32       # Add a FigureData to the map, return code indicates whether this data is
33       # already known (1), known and more recent (2) or not known at all (false).
34       #
35       # Map items are categorized into (:name, :year, :id, :period). This is a
36       # multi-dimensional Hash, see
37       # http://stackoverflow.com/questions/10253105/dynamically-creating-a-multi-dimensional-hash-in-ruby
38       #
39       # Unknown items will be map_added to the map. If a FigureData with the same
40       # categorization is passed, the map uses :time to decide whether to update
41       # or ignore it.
42       #
43       def map_add(figure_data)
44           unless (figure_data.is_a?(FigureData) or figure_data.is_a?(Smr::AutofigureData))
45               raise ':figure_data must be of FigureData or SmrAutofigureData'
46           end
47           @last_figure_data = figure_data
48           @last_figure_var = @last_figure_data.FigureVar
49   
50           # define locals to shorten the code below
51           name = @last_figure_var.name
52           id = @last_figure_var.id.to_i
53           year = @last_figure_data.time.year.to_i
54           period = @last_figure_data.period
55   
56           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
57               # we do not know this figure yet, map_add to map
58               @map[name][year][id][period] = @last_figure_data.time
59               return false
60           else
61               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
62                   # known but older
63                   return 1
64               else
65                   # known but more recent => update map
66                   @map[name][year][id][period] = figure_data.time
67                   return 2
68               end
69           end
70       end
71   
72       ##
73       # Return name last FigureVar processed by #map_add.
74       def get_name
75           @last_figure_var.name
76       end
77   
78       ##
79       # Return FigureData id of the last item processed by #map_add.
80       def get_figure_data_id
81           @last_figure_data.id
82       end
83   
84       ##
85       # Return period of the last item processed by #map_add.
86       def get_period
87           @last_figure_data.period
88       end
89   
90       ##
91       # Return date as +Time+ of the last item processed by #map_add.
92       def get_date
93           @last_figure_data.time
94       end
95   end
96   
97   ##
98   # Builds FigureVar and FigureData objects into a form that is easy to process
99   # in views.
100   #
101   # Use #add to push FigureData records into the table. When done, tell the object
102   # to process all data by calling #render. Thereafter all data is available for
103   # display through the +get_*+ methods.
104   #
105   # Note that methods return false or raise an exception when used their proper
106   # mode.
107   class FiguresDataTable < FiguresDataMap
109       ##
110       # mode of operation (input or display)
111       #
112       # The #add method is only active in input mode while all the +get_*+
113       # methods are only available in display mode. You must call #render
114       # before you can get any data out. A new object is in input mode.
115       attr_reader :input_mode
117       ##
118       # Most recent figures, see #add and #add_record.
119       attr_reader :most_recent_figures
121       ##
122       # All figures not most recent, see #add and #add_record.
123       attr_reader :less_recent_figures
125       ##
126       # Collection of years the table contains data for.
127       attr_reader :years
129       def initialize
130           super
132           @input_mode = true;
133           @most_recent_figures = Array.new
134           @less_recent_figures = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
135           @years = Array.new
137           # the final table created by #render
138           @table = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
140           # rowspanning information, see and use #rowspan
141           @rowspan = Hash.new
142       end
144       ##
145       # Process a FigureData while in input mode.
146       def add(figure_data)
147           raise 'add() only works in input mode, it stops working when #render was called' if not @input_mode
148   
149           case map_add(figure_data)
150               when false then add_record(figure_data)
151               when 1     then add_record(figure_data, {:less_recent=>true})
152               when 2     then update_data(figure_data)
153           end
154   
155           true
156       end
157   
158       ##
159       # End input mode and render all data ready for displaying.
160       #
161       # NOTE: @table is a multi-dimensional array but we use only two dimensions,
162       #       see http://stackoverflow.com/questions/10253105/dynamically-creating-a-multi-dimensional-hash-in-ruby
163       #
164       # Final table will look like when viewed by calling #cell on each
165       # +row+,+col+ coordinate:
166       #
167       #    ______ ________________ ____________ ______________ _______________ _____
168       #   |      |                | -  Year -  | - Quarter 1 -| - Quarter 2 - | ... |
169       #   | year | FigureVar.name | FigureData |  FigureData  |   FigureData  | ... |
170       #   |------|----------------|------------|--------------|---------------|-----|
171       #     ...
172       #
173       # The #text_table method is very useful for debugging.
174       #
175       def render
176           raise 'no data to #render, use add() first' if @most_recent_figures.empty?
177           @input_mode = false
178           @table.clear if @table.count > 0
179           periods = FigureData.new.get_periods
181   
182           # add most recent FigureVar
183           # - first: sort by FigureVar.time
184           # - second: add by period index + offset (because of the table head)
185           # ATTENTION: sorting by fd.FigureVar.name probably expensive, consider
186           #            fs.id_figure_var as performant alternative
187           @most_recent_figures.sort_by! { |fd| [fd.time.year, fd.FigureVar.name] }
188   
189           row=1
190           @most_recent_figures.each do |fd|
191               @table[row][0] = fd.time.year
192               @table[row][1] = fd.FigureVar.name
193   
194               col = periods.index(fd.period) + 2
195               @table[row][col] = fd
196       
197               row += 1
198           end
199   
200           # consolidate rows by years and figure name (first two columns)
201           prev_row = @table[1]
202           prev_key = 1
203           @table.each do |k, row|
204               if row[0]==prev_row[0] and row[1]==prev_row[1] then
205                   # merge into prev_row since row is more recent, see sort_by above
206                   @table[k] = prev_row.merge(row)
207                   @table.delete(prev_key) if k > prev_key
208               end
209               prev_row = @table[k]
210               prev_key = k
211           end
213           # init rowspanning
214           for row in 1..@table.count do
215               @rowspan.store(row, 0)
216           end
218           # sort table by years
219           # - all elements within a year keep their order as is
220           # - our @years accessor is ordered the same way
221           # - this also makes a new row-index beginning with 1
222           @years.sort!.reverse!
223           tmp_table = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) }
224           tmp = Array.new
225           row=1
227           @years.each{|y| tmp << @table.select {|k, v| v[0] == y } }
229           next_year = true; firstrowyear = 1;
230           for yi in 0..@years.count-1 do
231               tmp[yi].each do |k,v|
232                  tmp_table.store(row, v)
234                  # figure rowspanning
235                  firstrowyear = row if next_year
236                  @rowspan[firstrowyear] += 1
237                  next_year = false
238                  row += 1
239               end
240               next_year = true;
241           end
242           @table = tmp_table
244           # blank over-spanned lines with +false+ in first column
245           span = 0
246           for row in 2..@table.count do
247               if @rowspan[row]==0
248                  if not @rowspan[row-1].is_a?(FalseClass)
249                      span = @rowspan[row-1] if @rowspan[row-1] > 0
250                  end
251                  @rowspan[row]=false if span>0
252               end
253               span -= 1
254           end
256           # create table head
257           # - this will be row 0
258           # - table starts always with 0 and data follows from row-index 1+
259           @table[0][0] = ''
260           @table[0][1] = ''
261           periods.each_index {|i| @table[0][i+2]=FigureData.new.translate_period(periods[i])}
263           true
264       end
265   
266       ##
267       # print table in text format (for debugging only!)
268       def text_table
269           render
270           format = Array.new(columns, '%15s').join('|')
271   
272           puts '-' * ((columns+1) * 15)
273           for r in 0...rows
274               rowstr = Array.new
275               rowstr[0] = 'sp:%5s %4s ' % [rowspan(r), cell(r, 0)]
276               for c in 1..columns
277                   rowstr[c] = cell(r, c)
278               end
279               puts(format % rowstr)
280           end
281           puts '-' * ((columns+1) * 15)
282           true
283       end
284   
285       ##
286       # Returns number of rows after #render.
287       def rows
288           raise '#rows only works after #render was called' if @input_mode
289           @table.count
290       end
291   
292       ##
293       # Returns number of columns after #render.
294       # NOTE: the first row sets the number of columns of the table
295       def columns
296           raise '#columns only works after #render was called' if @input_mode
297           @table[0].count
298       end
299   
300       ##
301       # Returns content for a cell identified by row and col, nil if empty.
302       def cell(row, col)
303           raise '#cell only works after #render was called' if @input_mode
304   
305           if c = raw_cell_content(row, col) then
306               if c.is_a?(FigureData) or c.is_a?(Smr::AutofigureData)
307                   c.value
308               else
309                   # leftmost column is always a descriptive string, not to be
310                   # rounded, scaled, whatever ...
311                   if col==0 then c.to_s else c end
312               end
313           else
314               nil
315           end
316       end
317   
318       ##
319       # Returns unit of value in cell or nil if there is nothing.
320       def cell_unit(row, col)
321           raise '#cell_unit only works after #render was called' if @input_mode
322   
323           if c = raw_cell_content(row, col) then
324               if c.is_a?(FigureData) or c.is_a?(Smr::AutofigureData)
325                   c.FigureVar.unit
326               end
327           end
328       end
329   
330       ##
331       # Return rowspanning information for given row.
332       def rowspan(row)
333           raise '#rowspan only works after #render was called' if @input_mode
334           if @rowspan.key?(row) then @rowspan[row] else 1 end
335       end
336   
337       ##
338       # Return link to edit FigureVar of given field.
339       #
340       # False is returned if that field is empty.
341       def link(row, col)
342           raise '#link only works after #render was called' if @input_mode
343         
344           if c = raw_cell_content(row, col) then
345              if c.is_a?(FigureData) then return('/figures/%i/edit' % c.id) end
346           end
347         
348           false
349       end
350   
351   protected
352   
353       ##
354       # Add FigureData to internal @most_recent_figures or @less_recent_figures.
355       #
356       # #less_recent_figures contains all those where a more recent counterpart
357       # is available in #most_recent_figures. See #get_summary and #have_summary.
358       #
359       # #most_recent_figures is a regular +Array+.
360       #
361       # #less_recent_figures is a multi-dimensional +Hash+ structured as:
362       # 
363       #   @less_recent_figures[:year][:period][:id_figure_data] = Array.new
364       #
365       def add_record(figure_data, options={ :less_recent=>false })
366           y  = figure_data.time.year
367           p  = figure_data.period
368           id = figure_data.id
369   
370           if options[:less_recent] then
371               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
372                  @less_recent_figures[y][p][id] << figure_data
373               else
374                   @less_recent_figures[y][p][id] = [figure_data]
375               end
376           else
377               @most_recent_figures << figure_data
378               @years << y if not @years.include?(y)
379           end
380   
381           true
382       end
383   
384       ##
385       # Update FigureData item in #most_recent_figures, make sure the old data is
386       # kept in #less_recent_figures.
387       #
388       # FIXME: to be implemented!
389       def update_data(figure_data)
390           #@most_recent_figures.find(:id_figure_var=>figure_data.id_figure_var, ...)
391           true
392       end
393   
394       ##
395       # Returns content for a cell identified by row and col.
396       #
397       # The content can be +nil+ if that cell is empty or whatever is there, ie.
398       # a FigureData, a +String+, etc....
399       def raw_cell_content(row, col)
400           if @table.has_key?(row) and @table[row].has_key?(col) then
401                   @table[row][col]
402           else nil end
403       end
404   end
405   
406   ##
407   # Creates many (!) AutofigureData objects from a FigureVar record.
408   #
409   # A Autofigure is based on a FigureVar record with the :expression field
410   # containing a math expression. That is each FigureVar becomes a Autofigure
411   # by setting the :expression field. It will return to being a ordinary
412   # FigureVar by emptying the the :expression field.
413   #
414   # Each Autofigure will create as many AutofigureData objects as possible,
415   # depending on the information available to solve the given :expression. It
416   # might be hundreds if there is input data for, say, 100 fiscal quarters.
417   #
418   class Autofigure
420       # collection of human readdable errors, emtpy if all went well
421       attr_reader :errors
423       # Math functions users can use in the :expression field
424       SUPPORTED_FUNCTIONS = [:ceil, :floor, :sqrt, :cos, :sin, :tan, :exp, :log]
426       ##
427       # Init with FigureVar and a list of variables contained in its :expression.
428       # See SmrDaemonClient#parse_math_expressions.
429       def initialize(figure_var)
430           raise 'figure_var must be a FigureVar object' unless figure_var.is_a?(FigureVar)
431           raise 'figure_var must have :expression field set' if figure_var.expression.blank?
432           @autofigure = figure_var
433           @expression_vars = Hash.new
434           @errors = Array.new
436           # find variables used in expression
437           # - that is FigureVar objects that match by name used in :expression
438           # - internally variables are tracked by :id, not by :name (avoids db
439           #   queries later, when building maps)
440           variables = @autofigure.expression.strip.split(/\W+/).delete_if do |x|
441                 x.to_f != 0 or SUPPORTED_FUNCTIONS.include?(x.to_sym)
442           end
443           FigureVar.select(:id, :name).where(:name=>variables).each do |v|
444               @expression_vars[v.id]=v.name
445           end
446   
447           # collects FigureData by year and period, to know what we have (=>
448           # #add()) and still need (=> #get_missing_variables)
449           @datamatrix = Hash.new
450   
451           # collects Time if most recent FigureData per row in @datamatrix,
452           # necessary to know how to time the SmrAutofigureData objects
453           @timematrix = Hash.new
454   
455           @id_security = nil
456       end
457   
458       ##
459       # Add FigureData as input for solving the expression.
460       #
461       # Returns +true+ if it was added or +false+ if it did not help to fill this
462       # expression.
463       #
464       # Note: data is overwritten. A FigureData of same +:year+ and +:period+
465       # will overwrite the previously passed one. So mind the order.
466       def add(figure_data)
467           raise 'figure_data must be a FigureData object' unless figure_data.is_a?(FigureData)
468   
469           if @id_security and @id_security != figure_data.id_security
470               raise 'add()ing FigureData of another Security should not happen, should it?'
471           else @id_security = figure_data.id_security end
472   
473           i = '%s_%s' % [figure_data.time.year, figure_data.period ]
474           if @expression_vars.keys.include?(figure_data.id_figure_var)
475               newdata = { figure_data.id_figure_var=>figure_data.value }
476               @datamatrix[i] = if @datamatrix[i] then @datamatrix[i].merge(newdata) else newdata end
477   
478               if not @timematrix[i] or @timematrix[i] < figure_data.time then
479                   @timematrix[i] = figure_data.time
480               end
481   
482               return true
483           end
484   
485           return false
486       end
487   
488       ##
489       # Tells whats missing to solve all expressions.
490       def get_missing_variables
491           missing = Array.new
492           @datamatrix.each do |i,d|
493               diff = @expression_vars.keys - d.keys
494               if not diff.empty?
495                   missing << i.to_s + '_' + @expression_vars.select{|k,v| diff.include?(k) }.values.join('-')
496               end
497           end
498           missing
499       end
501       ##
502       # Returns collection of AutofigureData objects with solved
503       # expressions.
504       #
505       # Objects with non-solvable expressions due to missing data are
506       # skipped silently (see #get_missing_variables).
507       #
508       # Exceptions from MathEngine will be catched and collected in #errors.
509       def get
510           @datamatrix.collect do |i,d|
511               diff = @expression_vars.keys - d.keys
512               if diff.empty?
513                   e = MathEngine.new
514                   e.context.include_library Smr::AutofigureMathFunctions.new
516                   af, time, period, id = @autofigure, @timematrix[i], i.split('_').second, @id_security
518                 begin
519                   # init variables in MathEngine instance, then ...
520                   d.each{ |k,v|
521                     e.context.set(@expression_vars[k].to_sym,v)
522                   }
524                   # ... solve the expression as result of a new AutofigureData
525                   AutofigureData.new(af, e.evaluate(af.expression), id, period, time)
526                 rescue #=> exception
527 # p exception.backtrace
528 # p '=E=> %s' %  e.inspect
529                    @errors << 'solving %s(%s-%s) failed with: %s' % [af.name, time.year, period, $! ]
530                    nil
531                 end
532               end
533           end.compact
534       end
535   
536   end
537   
538   ##
539   # Like a FigureData object in behaviour but without related database record.
540   #
541   # A AutofigureData object is ment to behave exactly like a FigureData
542   # object. Except that it can`t be saved or updated or trigger any other
543   # database operation. Its an entirely artificial record so to say.
544   class AutofigureData
545       # compatibility with FigureData, always +nil+
546       attr_reader :id
547   
548       # compatibility with FigureData, empty
549       attr_reader :analyst, :is_expected, :is_audited, :comment
550   
551       # where this autofigure was derived from
552       attr_reader :id_figure_var, :id_security, :period, :expression
553   
554       # unix timestamp when this autofigure was made, also see time()
555       attr_reader :date
556   
557       # result of the expression or +nil+ if not yet solved
558       # Note: must be calculated/set externaly, Smr::Autofigure#get does this
559       attr_accessor :value
560   
561       ##
562       # Give FigureVar out of which this Smr::AutofigureData was derived and the result
563       # from solving the expression.
564       # - :id_security+ is to know what this is for
565       # - the given FigureVar must have a :expression, otherwise its not a autofigure
566       def initialize(autofigure, result, id_security, period, time)
567           raise ':autofigure must be a FigureVar' unless autofigure.is_a? FigureVar
568           raise ':autofigure :expression must be set' if autofigure.expression.blank?
569           raise ':result must be numerical' unless result.is_a? Numeric
570           raise ':time must be of Time' unless time.is_a? Time
571           @autofigure = autofigure
572           @expression = autofigure.expression
573           @value = result
574   
575           # carry on meaningful fields
576           @id_figure_var = autofigure.id
577           @id_security = id_security
578           @date = time.to_i
579           @period = period
580           @analyst = self.class
581       end
582   
583       ##
584       # Time when this autofigure was made.
585       def time
586           Time.at(@date)
587       end
588   
589       ##
590       # Wrapper for compatibility with FigureData.
591       def get_periods
592           FigureData.new.get_periods
593       end
594   
595       ##
596       # Wrapper for compatibility with FigureData.
597       def translate_period(period=@period)
598           FigureData.new.translate_period(period)
599       end
600   
601       ##
602       # Wrapper for compatibility with FigureData.
603       def FigureVar
604           @autofigure
605       end
606   end
608   ##
609   # additional methods available to users in FigureVar#expression field.
610   class AutofigureMathFunctions
611     def ceil(v)
612         v.to_f.ceil
613     end
615     def floor(v)
616         v.to_f.floor
617     end
618   end
620 end # module