source: trunk/python/asaplotbase.py@ 1162

Last change on this file since 1162 was 1153, checked in by mar637, 18 years ago

lots of changes to support soft refresh, for things like text overlays, linecatlogs etc. reworked plot_lines to to auto-peak detection. added forwarding functions to matplotlib.axes. drawing functions

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
File size: 26.4 KB
Line 
1"""
2ASAP plotting class based on matplotlib.
3"""
4
5import sys
6from re import match
7
8import matplotlib
9
10from matplotlib.figure import Figure, Text
11from matplotlib.font_manager import FontProperties as FP
12from matplotlib.numerix import sqrt
13from matplotlib import rc, rcParams
14from asap import rcParams as asaprcParams
15from matplotlib.ticker import ScalarFormatter
16from matplotlib.ticker import NullLocator
17from matplotlib.transforms import blend_xy_sep_transform
18
19if int(matplotlib.__version__.split(".")[1]) < 87:
20 print "Warning: matplotlib version < 0.87. This might cause errors. Please upgrade."
21
22class MyFormatter(ScalarFormatter):
23 def __call__(self, x, pos=None):
24 #last = len(self.locs)-2
25 if pos==0:
26 return ''
27 else: return ScalarFormatter.__call__(self, x, pos)
28
29class asaplotbase:
30 """
31 ASAP plotting base class based on matplotlib.
32 """
33
34 def __init__(self, rows=1, cols=0, title='', size=(8,6), buffering=False):
35 """
36 Create a new instance of the ASAPlot plotting class.
37
38 If rows < 1 then a separate call to set_panels() is required to define
39 the panel layout; refer to the doctext for set_panels().
40 """
41 self.is_dead = False
42 self.figure = Figure(figsize=size, facecolor='#ddddee')
43 self.canvas = None
44
45 self.set_title(title)
46 self.subplots = []
47 if rows > 0:
48 self.set_panels(rows, cols)
49
50 # Set matplotlib default colour sequence.
51 self.colormap = "green red black cyan magenta orange blue purple yellow pink".split()
52
53 c = asaprcParams['plotter.colours']
54 if isinstance(c,str) and len(c) > 0:
55 self.colormap = c.split()
56
57 self.lsalias = {"line": [1,0],
58 "dashdot": [4,2,1,2],
59 "dashed" : [4,2,4,2],
60 "dotted" : [1,2],
61 "dashdotdot": [4,2,1,2,1,2],
62 "dashdashdot": [4,2,4,2,1,2]
63 }
64
65 styles = "line dashed dotted dashdot".split()
66 c = asaprcParams['plotter.linestyles']
67 if isinstance(c,str) and len(c) > 0:
68 styles = c.split()
69 s = []
70 for ls in styles:
71 if self.lsalias.has_key(ls):
72 s.append(self.lsalias.get(ls))
73 else:
74 s.append('-')
75 self.linestyles = s
76
77 self.color = 0;
78 self.linestyle = 0;
79 self.attributes = {}
80 self.loc = 0
81
82 self.buffering = buffering
83
84 def clear(self):
85 """
86 Delete all lines from the plot. Line numbering will restart from 0.
87 """
88
89 for i in range(len(self.lines)):
90 self.delete(i)
91 self.axes.clear()
92 self.color = 0
93 self.lines = []
94
95 def palette(self, color, colormap=None, linestyle=0, linestyles=None):
96 if colormap:
97 if isinstance(colormap,list):
98 self.colormap = colormap
99 elif isinstance(colormap,str):
100 self.colormap = colormap.split()
101 if 0 <= color < len(self.colormap):
102 self.color = color
103 if linestyles:
104 self.linestyles = []
105 if isinstance(linestyles,list):
106 styles = linestyles
107 elif isinstance(linestyles,str):
108 styles = linestyles.split()
109 for ls in styles:
110 if self.lsalias.has_key(ls):
111 self.linestyles.append(self.lsalias.get(ls))
112 else:
113 self.linestyles.append(self.lsalias.get('line'))
114 if 0 <= linestyle < len(self.linestyles):
115 self.linestyle = linestyle
116
117 def delete(self, numbers=None):
118 """
119 Delete the 0-relative line number, default is to delete the last.
120 The remaining lines are NOT renumbered.
121 """
122
123 if numbers is None: numbers = [len(self.lines)-1]
124
125 if not hasattr(numbers, '__iter__'):
126 numbers = [numbers]
127
128 for number in numbers:
129 if 0 <= number < len(self.lines):
130 if self.lines[number] is not None:
131 for line in self.lines[number]:
132 line.set_linestyle('None')
133 self.lines[number] = None
134 self.show()
135
136 def get_line(self):
137 """
138 Get the current default line attributes.
139 """
140 return self.attributes
141
142
143 def hist(self, x=None, y=None, fmt=None, add=None):
144 """
145 Plot a histogram. N.B. the x values refer to the start of the
146 histogram bin.
147
148 fmt is the line style as in plot().
149 """
150 from matplotlib.numerix import array
151 from matplotlib.numerix.ma import MaskedArray
152 if x is None:
153 if y is None: return
154 x = range(len(y))
155
156 if len(x) != len(y):
157 return
158 l2 = 2*len(x)
159 x2 = range(l2)
160 y2 = range(12)
161 y2 = range(l2)
162 m2 = range(l2)
163 ymsk = y.raw_mask()
164 ydat = y.raw_data()
165 for i in range(l2):
166 x2[i] = x[i/2]
167 m2[i] = ymsk[i/2]
168
169 y2[0] = 0.0
170 for i in range(1,l2):
171 y2[i] = ydat[(i-1)/2]
172
173 self.plot(x2, MaskedArray(y2,mask=m2,copy=0), fmt, add)
174
175
176 def hold(self, hold=True):
177 """
178 Buffer graphics until subsequently released.
179 """
180 self.buffering = hold
181
182
183 def legend(self, loc=None):
184 """
185 Add a legend to the plot.
186
187 Any other value for loc else disables the legend:
188 1: upper right
189 2: upper left
190 3: lower left
191 4: lower right
192 5: right
193 6: center left
194 7: center right
195 8: lower center
196 9: upper center
197 10: center
198
199 """
200 if isinstance(loc, int):
201 self.loc = None
202 if 0 <= loc <= 10: self.loc = loc
203 else:
204 self.loc = None
205 #self.show()
206
207
208 def plot(self, x=None, y=None, fmt=None, add=None):
209 """
210 Plot the next line in the current frame using the current line
211 attributes. The ASAPlot graphics window will be mapped and raised.
212
213 The argument list works a bit like the matlab plot() function.
214 """
215 if x is None:
216 if y is None: return
217 x = range(len(y))
218
219 elif y is None:
220 y = x
221 x = range(len(y))
222 if fmt is None:
223 line = self.axes.plot(x, y)
224 else:
225 line = self.axes.plot(x, y, fmt)
226
227 # Add to an existing line?
228 i = None
229 if add is None or len(self.lines) < add < 0:
230 # Don't add.
231 self.lines.append(line)
232 i = len(self.lines) - 1
233 else:
234 if add == 0: add = len(self.lines)
235 i = add - 1
236 self.lines[i].extend(line)
237
238 # Set/reset attributes for the line.
239 gotcolour = False
240 for k, v in self.attributes.iteritems():
241 if k == 'color': gotcolour = True
242 for segment in self.lines[i]:
243 getattr(segment, "set_%s"%k)(v)
244
245 if not gotcolour and len(self.colormap):
246 for segment in self.lines[i]:
247 getattr(segment, "set_color")(self.colormap[self.color])
248 if len(self.colormap) == 1:
249 getattr(segment, "set_dashes")(self.linestyles[self.linestyle])
250
251 self.color += 1
252 if self.color >= len(self.colormap):
253 self.color = 0
254
255 if len(self.colormap) == 1:
256 self.linestyle += 1
257 if self.linestyle >= len(self.linestyles):
258 self.linestyle = 0
259
260 self.show()
261
262
263 def position(self):
264 """
265 Use the mouse to get a position from a graph.
266 """
267
268 def position_disable(event):
269 self.register('button_press', None)
270 print '%.4f, %.4f' % (event.xdata, event.ydata)
271
272 print 'Press any mouse button...'
273 self.register('button_press', position_disable)
274
275
276 def region(self):
277 """
278 Use the mouse to get a rectangular region from a plot.
279
280 The return value is [x0, y0, x1, y1] in world coordinates.
281 """
282
283 def region_start(event):
284 height = self.canvas.figure.bbox.height()
285 self.rect = {'fig': None, 'height': height,
286 'x': event.x, 'y': height - event.y,
287 'world': [event.xdata, event.ydata,
288 event.xdata, event.ydata]}
289 self.register('button_press', None)
290 self.register('motion_notify', region_draw)
291 self.register('button_release', region_disable)
292
293 def region_draw(event):
294 self.canvas._tkcanvas.delete(self.rect['fig'])
295 self.rect['fig'] = self.canvas._tkcanvas.create_rectangle(
296 self.rect['x'], self.rect['y'],
297 event.x, self.rect['height'] - event.y)
298
299 def region_disable(event):
300 self.register('motion_notify', None)
301 self.register('button_release', None)
302
303 self.canvas._tkcanvas.delete(self.rect['fig'])
304
305 self.rect['world'][2:4] = [event.xdata, event.ydata]
306 print '(%.2f, %.2f) (%.2f, %.2f)' % (self.rect['world'][0],
307 self.rect['world'][1], self.rect['world'][2],
308 self.rect['world'][3])
309
310 self.register('button_press', region_start)
311
312 # This has to be modified to block and return the result (currently
313 # printed by region_disable) when that becomes possible in matplotlib.
314
315 return [0.0, 0.0, 0.0, 0.0]
316
317
318 def register(self, type=None, func=None):
319 """
320 Register, reregister, or deregister events of type 'button_press',
321 'button_release', or 'motion_notify'.
322
323 The specified callback function should have the following signature:
324
325 def func(event)
326
327 where event is an MplEvent instance containing the following data:
328
329 name # Event name.
330 canvas # FigureCanvas instance generating the event.
331 x = None # x position - pixels from left of canvas.
332 y = None # y position - pixels from bottom of canvas.
333 button = None # Button pressed: None, 1, 2, 3.
334 key = None # Key pressed: None, chr(range(255)), shift,
335 win, or control
336 inaxes = None # Axes instance if cursor within axes.
337 xdata = None # x world coordinate.
338 ydata = None # y world coordinate.
339
340 For example:
341
342 def mouse_move(event):
343 print event.xdata, event.ydata
344
345 a = asaplot()
346 a.register('motion_notify', mouse_move)
347
348 If func is None, the event is deregistered.
349
350 Note that in TkAgg keyboard button presses don't generate an event.
351 """
352
353 if not self.events.has_key(type): return
354
355 if func is None:
356 if self.events[type] is not None:
357 # It's not clear that this does anything.
358 self.canvas.mpl_disconnect(self.events[type])
359 self.events[type] = None
360
361 # It seems to be necessary to return events to the toolbar.
362 if type == 'motion_notify':
363 self.canvas.mpl_connect(type + '_event',
364 self.figmgr.toolbar.mouse_move)
365 elif type == 'button_press':
366 self.canvas.mpl_connect(type + '_event',
367 self.figmgr.toolbar.press)
368 elif type == 'button_release':
369 self.canvas.mpl_connect(type + '_event',
370 self.figmgr.toolbar.release)
371
372 else:
373 self.events[type] = self.canvas.mpl_connect(type + '_event', func)
374
375
376 def release(self):
377 """
378 Release buffered graphics.
379 """
380 self.buffering = False
381 self.show()
382
383
384 def save(self, fname=None, orientation=None, dpi=None, papertype=None):
385 """
386 Save the plot to a file.
387
388 fname is the name of the output file. The image format is determined
389 from the file suffix; 'png', 'ps', and 'eps' are recognized. If no
390 file name is specified 'yyyymmdd_hhmmss.png' is created in the current
391 directory.
392 """
393 from asap import rcParams
394 if papertype is None:
395 papertype = rcParams['plotter.papertype']
396 if fname is None:
397 from datetime import datetime
398 dstr = datetime.now().strftime('%Y%m%d_%H%M%S')
399 fname = 'asap'+dstr+'.png'
400
401 d = ['png','.ps','eps']
402
403 from os.path import expandvars
404 fname = expandvars(fname)
405
406 if fname[-3:].lower() in d:
407 try:
408 if fname[-3:].lower() == ".ps":
409 from matplotlib import __version__ as mv
410 w = self.figure.figwidth.get()
411 h = self.figure.figheight.get()
412
413 if orientation is None:
414 # oriented
415 if w > h:
416 orientation = 'landscape'
417 else:
418 orientation = 'portrait'
419 from matplotlib.backends.backend_ps import papersize
420 pw,ph = papersize[papertype.lower()]
421 ds = None
422 if orientation == 'landscape':
423 ds = min(ph/w, pw/h)
424 else:
425 ds = min(pw/w, ph/h)
426 ow = ds * w
427 oh = ds * h
428 self.figure.set_figsize_inches((ow, oh))
429 self.figure.savefig(fname, orientation=orientation,
430 papertype=papertype.lower())
431 self.figure.set_figsize_inches((w, h))
432 print 'Written file %s' % (fname)
433 else:
434 if dpi is None:
435 dpi =150
436 self.figure.savefig(fname,dpi=dpi)
437 print 'Written file %s' % (fname)
438 except IOError, msg:
439 print 'Failed to save %s: Error msg was\n\n%s' % (fname, err)
440 return
441 else:
442 print "Invalid image type. Valid types are:"
443 print "'ps', 'eps', 'png'"
444
445
446 def set_axes(self, what=None, *args, **kwargs):
447 """
448 Set attributes for the axes by calling the relevant Axes.set_*()
449 method. Colour translation is done as described in the doctext
450 for palette().
451 """
452
453 if what is None: return
454 if what[-6:] == 'colour': what = what[:-6] + 'color'
455
456 key = "colour"
457 if kwargs.has_key(key):
458 val = kwargs.pop(key)
459 kwargs["color"] = val
460
461 getattr(self.axes, "set_%s"%what)(*args, **kwargs)
462
463 self.show(hardrefresh=False)
464
465
466 def set_figure(self, what=None, *args, **kwargs):
467 """
468 Set attributes for the figure by calling the relevant Figure.set_*()
469 method. Colour translation is done as described in the doctext
470 for palette().
471 """
472
473 if what is None: return
474 if what[-6:] == 'colour': what = what[:-6] + 'color'
475 #if what[-5:] == 'color' and len(args):
476 # args = (get_colour(args[0]),)
477
478 newargs = {}
479 for k, v in kwargs.iteritems():
480 k = k.lower()
481 if k == 'colour': k = 'color'
482 newargs[k] = v
483
484 getattr(self.figure, "set_%s"%what)(*args, **newargs)
485 self.show(hardrefresh=False)
486
487
488 def set_limits(self, xlim=None, ylim=None):
489 """
490 Set x-, and y-limits for each subplot.
491
492 xlim = [xmin, xmax] as in axes.set_xlim().
493 ylim = [ymin, ymax] as in axes.set_ylim().
494 """
495 for s in self.subplots:
496 self.axes = s['axes']
497 self.lines = s['lines']
498 oldxlim = list(self.axes.get_xlim())
499 oldylim = list(self.axes.get_ylim())
500 if xlim is not None:
501 for i in range(len(xlim)):
502 if xlim[i] is not None:
503 oldxlim[i] = xlim[i]
504 if ylim is not None:
505 for i in range(len(ylim)):
506 if ylim[i] is not None:
507 oldylim[i] = ylim[i]
508 self.axes.set_xlim(oldxlim)
509 self.axes.set_ylim(oldylim)
510 return
511
512
513 def set_line(self, number=None, **kwargs):
514 """
515 Set attributes for the specified line, or else the next line(s)
516 to be plotted.
517
518 number is the 0-relative number of a line that has already been
519 plotted. If no such line exists, attributes are recorded and used
520 for the next line(s) to be plotted.
521
522 Keyword arguments specify Line2D attributes, e.g. color='r'. Do
523
524 import matplotlib
525 help(matplotlib.lines)
526
527 The set_* methods of class Line2D define the attribute names and
528 values. For non-US usage, "colour" is recognized as synonymous with
529 "color".
530
531 Set the value to None to delete an attribute.
532
533 Colour translation is done as described in the doctext for palette().
534 """
535
536 redraw = False
537 for k, v in kwargs.iteritems():
538 k = k.lower()
539 if k == 'colour': k = 'color'
540
541 if 0 <= number < len(self.lines):
542 if self.lines[number] is not None:
543 for line in self.lines[number]:
544 getattr(line, "set_%s"%k)(v)
545 redraw = True
546 else:
547 if v is None:
548 del self.attributes[k]
549 else:
550 self.attributes[k] = v
551
552 if redraw: self.show(hardrefresh=False)
553
554
555 def set_panels(self, rows=1, cols=0, n=-1, nplots=-1, ganged=True):
556 """
557 Set the panel layout.
558
559 rows and cols, if cols != 0, specify the number of rows and columns in
560 a regular layout. (Indexing of these panels in matplotlib is row-
561 major, i.e. column varies fastest.)
562
563 cols == 0 is interpreted as a retangular layout that accomodates
564 'rows' panels, e.g. rows == 6, cols == 0 is equivalent to
565 rows == 2, cols == 3.
566
567 0 <= n < rows*cols is interpreted as the 0-relative panel number in
568 the configuration specified by rows and cols to be added to the
569 current figure as its next 0-relative panel number (i). This allows
570 non-regular panel layouts to be constructed via multiple calls. Any
571 other value of n clears the plot and produces a rectangular array of
572 empty panels. The number of these may be limited by nplots.
573 """
574 if n < 0 and len(self.subplots):
575 self.figure.clear()
576 self.set_title()
577
578 if rows < 1: rows = 1
579
580 if cols <= 0:
581 i = int(sqrt(rows))
582 if i*i < rows: i += 1
583 cols = i
584
585 if i*(i-1) >= rows: i -= 1
586 rows = i
587
588 if 0 <= n < rows*cols:
589 i = len(self.subplots)
590 self.subplots.append({})
591
592 self.subplots[i]['axes'] = self.figure.add_subplot(rows,
593 cols, n+1)
594 self.subplots[i]['lines'] = []
595
596 if i == 0: self.subplot(0)
597
598 self.rows = 0
599 self.cols = 0
600
601 else:
602 self.subplots = []
603
604 if nplots < 1 or rows*cols < nplots:
605 nplots = rows*cols
606 if ganged:
607 hsp,wsp = None,None
608 if rows > 1: hsp = 0.0001
609 if cols > 1: wsp = 0.0001
610 self.figure.subplots_adjust(wspace=wsp,hspace=hsp)
611 for i in range(nplots):
612 self.subplots.append({})
613 self.subplots[i]['lines'] = []
614 if not ganged:
615 self.subplots[i]['axes'] = self.figure.add_subplot(rows,
616 cols, i+1)
617 else:
618 if i == 0:
619 self.subplots[i]['axes'] = self.figure.add_subplot(rows,
620 cols, i+1)
621 else:
622 self.subplots[i]['axes'] = self.figure.add_subplot(rows,
623 cols, i+1,
624 sharex=self.subplots[0]['axes'],
625 sharey=self.subplots[0]['axes'])
626 # Suppress tick labelling for interior subplots.
627 if i <= (rows-1)*cols - 1:
628 if i+cols < nplots:
629 # Suppress x-labels for frames width
630 # adjacent frames
631 for tick in self.subplots[i]['axes'].xaxis.majorTicks:
632 tick.label1On = False
633 self.subplots[i]['axes'].xaxis.label.set_visible(False)
634 if i%cols:
635 # Suppress y-labels for frames not in the left column.
636 for tick in self.subplots[i]['axes'].yaxis.majorTicks:
637 tick.label1On = False
638 self.subplots[i]['axes'].yaxis.label.set_visible(False)
639 # disable the first tick of [1:ncol-1] of the last row
640 #if i+1 < nplots:
641 # self.subplots[i]['axes'].xaxis.majorTicks[0].label1On = False
642 self.rows = rows
643 self.cols = cols
644 self.subplot(0)
645
646 def tidy(self):
647 # this needs to be exceuted after the first "refresh"
648 nplots = len(self.subplots)
649 if nplots == 1: return
650 for i in xrange(nplots):
651 ax = self.subplots[i]['axes']
652 if i%self.cols:
653 ax.xaxis.majorTicks[0].label1On = False
654 else:
655 if i != 0:
656 ax.yaxis.majorTicks[-1].label1On = False
657
658
659 def set_title(self, title=None):
660 """
661 Set the title of the plot window. Use the previous title if title is
662 omitted.
663 """
664 if title is not None:
665 self.title = title
666
667 self.figure.text(0.5, 0.95, self.title, horizontalalignment='center')
668
669
670 def show(self, hardrefresh=True):
671 """
672 Show graphics dependent on the current buffering state.
673 """
674 if not hardrefresh: return
675 if not self.buffering:
676 if self.loc is not None:
677 for sp in self.subplots:
678 lines = []
679 labels = []
680 i = 0
681 for line in sp['lines']:
682 i += 1
683 if line is not None:
684 lines.append(line[0])
685 lbl = line[0].get_label()
686 if lbl == '':
687 lbl = str(i)
688 labels.append(lbl)
689
690 if len(lines):
691 fp = FP(size=rcParams['legend.fontsize'])
692 fsz = fp.get_size_in_points() - len(lines)
693 fp.set_size(max(fsz,6))
694 sp['axes'].legend(tuple(lines), tuple(labels),
695 self.loc, prop=fp)
696 else:
697 sp['axes'].legend((' '))
698
699 from matplotlib.artist import setp
700 fp = FP(size=rcParams['xtick.labelsize'])
701 xts = fp.get_size_in_points()- (self.cols)/2
702 fp = FP(size=rcParams['ytick.labelsize'])
703 yts = fp.get_size_in_points() - (self.rows)/2
704 for sp in self.subplots:
705 ax = sp['axes']
706 s = ax.title.get_size()
707 tsize = s-(self.cols+self.rows)
708 ax.title.set_size(tsize)
709 fp = FP(size=rcParams['axes.labelsize'])
710 setp(ax.get_xticklabels(), fontsize=xts)
711 setp(ax.get_yticklabels(), fontsize=yts)
712 origx = fp.get_size_in_points()
713 origy = origx
714 off = 0
715 if self.cols > 1: off = self.cols
716 xfsize = origx-off
717 ax.xaxis.label.set_size(xfsize)
718 off = 0
719 if self.rows > 1: off = self.rows
720 yfsize = origy-off
721 ax.yaxis.label.set_size(yfsize)
722
723 def subplot(self, i=None, inc=None):
724 """
725 Set the subplot to the 0-relative panel number as defined by one or
726 more invokations of set_panels().
727 """
728 l = len(self.subplots)
729 if l:
730 if i is not None:
731 self.i = i
732
733 if inc is not None:
734 self.i += inc
735
736 self.i %= l
737 self.axes = self.subplots[self.i]['axes']
738 self.lines = self.subplots[self.i]['lines']
739
740 def text(self, *args, **kwargs):
741 """
742 Add text to the figure.
743 """
744 self.figure.text(*args, **kwargs)
745 self.show()
746
747 def vline_with_label(self, x, y, label,
748 location='bottom', rotate=0.0, **kwargs):
749 """
750 Plot a vertical line with label.
751 It takes "world" values fo x and y.
752 """
753 ax = self.axes
754 # need this to suppress autoscaling during this function
755 self.axes.set_autoscale_on(False)
756 ymin = 0.0
757 ymax = 1.0
758 valign = 'center'
759 if location.lower() == 'top':
760 y = max(0.0, y)
761 elif location.lower() == 'bottom':
762 y = min(0.0, y)
763 lbloffset = 0.06
764 # a rough estimate for the bb of the text
765 if rotate > 0.0: lbloffset = 0.03*len(label)
766 peakoffset = 0.01
767 xy0 = ax.transData.xy_tup((x,y))
768 # get relative coords
769 xy = ax.transAxes.inverse_xy_tup(xy0)
770 if location.lower() == 'top':
771 ymax = 1.0-lbloffset
772 ymin = xy[1]+peakoffset
773 valign = 'bottom'
774 ylbl = ymax+0.01
775 elif location.lower() == 'bottom':
776 ymin = lbloffset
777 ymax = xy[1]-peakoffset
778 valign = 'top'
779 ylbl = ymin-0.01
780 trans = blend_xy_sep_transform(ax.transData, ax.transAxes)
781 l = ax.axvline(x, ymin, ymax, color='black', **kwargs)
782 t = ax.text(x, ylbl ,label, verticalalignment=valign,
783 horizontalalignment='center',
784 rotation=rotate,transform = trans)
785 self.axes.set_autoscale_on(True)
Note: See TracBrowser for help on using the repository browser.