3d function plots
[PyX/mjg.git] / pyx / graph / data.py
blob8ce2607cbf82d67bb1a08e2292d682439d1e457e
1 # -*- coding: ISO-8859-1 -*-
4 # Copyright (C) 2002-2004 Jörg Lehmann <joergl@users.sourceforge.net>
5 # Copyright (C) 2003-2004 Michael Schindler <m-schindler@users.sourceforge.net>
6 # Copyright (C) 2002-2006 André Wobst <wobsta@users.sourceforge.net>
8 # This file is part of PyX (http://pyx.sourceforge.net/).
10 # PyX is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 2 of the License, or
13 # (at your option) any later version.
15 # PyX is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 # GNU General Public License for more details.
20 # You should have received a copy of the GNU General Public License
21 # along with PyX; if not, write to the Free Software
22 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
24 from __future__ import nested_scopes
26 import math, re, ConfigParser, struct, warnings
27 from pyx import text
28 from pyx.style import linestyle
29 from pyx.graph import style
31 try:
32 enumerate([])
33 except NameError:
34 # fallback implementation for Python 2.2 and below
35 def enumerate(list):
36 return zip(xrange(len(list)), list)
38 try:
39 dict()
40 except NameError:
41 # fallback implementation for Python 2.1
42 def dict(items):
43 result = {}
44 for key, value in items:
45 result[key] = value
46 return result
49 def splitatvalue(value, *splitpoints):
50 section = 0
51 while section < len(splitpoints) and splitpoints[section] < value:
52 section += 1
53 if len(splitpoints) > 1:
54 if section % 2:
55 section = None
56 else:
57 section >>= 1
58 return (section, value)
61 _mathglobals = {"neg": lambda x: -x,
62 "abs": lambda x: x < 0 and -x or x,
63 "sgn": lambda x: x < 0 and -1 or 1,
64 "sqrt": math.sqrt,
65 "exp": math.exp,
66 "log": math.log,
67 "sin": math.sin,
68 "cos": math.cos,
69 "tan": math.tan,
70 "asin": math.asin,
71 "acos": math.acos,
72 "atan": math.atan,
73 "sind": lambda x: math.sin(math.pi/180*x),
74 "cosd": lambda x: math.cos(math.pi/180*x),
75 "tand": lambda x: math.tan(math.pi/180*x),
76 "asind": lambda x: 180/math.pi*math.asin(x),
77 "acosd": lambda x: 180/math.pi*math.acos(x),
78 "atand": lambda x: 180/math.pi*math.atan(x),
79 "norm": lambda x, y: math.hypot(x, y),
80 "splitatvalue": splitatvalue,
81 "pi": math.pi,
82 "e": math.e}
85 class _data:
86 """graph data interface
88 Graph data consists in columns, where each column might be identified by a
89 string or an integer. Each row in the resulting table refers to a data
90 point.
92 All methods except for the constructor should consider self and its
93 attributes to be readonly, since the data instance might be shared between
94 several graphs simultaniously.
96 The instance variable columns is a dictionary mapping column names to the
97 data of the column (i.e. to a list). Only static columns (known at
98 construction time) are contained in that dictionary. For data with numbered
99 columns the column data is also available via the list columndata.
100 Otherwise the columndata list should be missing and an access to a column
101 number will fail.
103 The names of all columns (static and dynamic) must be fixed at the constructor
104 and stated in the columnnames dictionary.
106 The instance variable title and defaultstyles contain the data title and
107 the default styles (a list of styles), respectively.
110 def dynamiccolumns(self, graph):
111 """create and return dynamic columns data
113 Returns dynamic data matching the given axes (the axes range and other
114 data might be used). The return value is a dictionary similar to the
115 columns instance variable.
117 return {}
120 defaultsymbols = [style.symbol()]
121 defaultlines = [style.line()]
124 class values(_data):
126 defaultstyles = defaultsymbols
128 def __init__(self, title="user provided values", **columns):
129 for i, values in enumerate(columns.values()):
130 if i and len(values) != l:
131 raise ValueError("different number of values")
132 else:
133 l = len(values)
134 self.columns = columns
135 self.columnnames = columns.keys()
136 self.title = title
139 class points(_data):
140 "Graph data from a list of points"
142 defaultstyles = defaultsymbols
144 def __init__(self, points, title="user provided points", addlinenumbers=1, **columns):
145 if len(points):
146 l = len(points[0])
147 self.columndata = [[x] for x in points[0]]
148 for point in points[1:]:
149 if l != len(point):
150 raise ValueError("different number of columns per point")
151 for i, x in enumerate(point):
152 self.columndata[i].append(x)
153 for v in columns.values():
154 if abs(v) > l or (not addlinenumbers and abs(v) == l):
155 raise ValueError("column number bigger than number of columns")
156 if addlinenumbers:
157 self.columndata = [range(1, len(points) + 1)] + self.columndata
158 self.columns = dict([(key, self.columndata[i]) for key, i in columns.items()])
159 else:
160 self.columns = dict([(key, []) for key, i in columns])
161 self.columnnames = self.columns.keys()
162 self.title = title
165 def list(*args, **kwargs):
166 warnings.warn("graph.data.list is deprecated. Use graph.data.points instead.")
167 return points(*args, **kwargs)
170 class _notitle:
171 pass
173 _columnintref = re.compile(r"\$(-?\d+)", re.IGNORECASE)
175 class data(_data):
176 "creates a new data set out of an existing data set"
178 def __init__(self, data, title=_notitle, context={}, copy=1,
179 replacedollar=1, columncallback="__column__", **columns):
180 # build a nice title
181 if title is _notitle:
182 items = columns.items()
183 items.sort() # we want sorted items (otherwise they would be unpredictable scrambled)
184 self.title = "%s: %s" % (text.escapestring(data.title or "unkown source"),
185 ", ".join(["%s=%s" % (text.escapestring(key),
186 text.escapestring(str(value)))
187 for key, value in items]))
188 else:
189 self.title = title
191 self.orgdata = data
192 self.defaultstyles = self.orgdata.defaultstyles
194 # analyse the **columns argument
195 self.columns = {}
196 for columnname, value in columns.items():
197 # search in the columns dictionary
198 try:
199 self.columns[columnname] = self.orgdata.columns[value]
200 except KeyError:
201 # search in the columndata list
202 try:
203 self.columns[columnname] = self.orgdata.columndata[value]
204 except (AttributeError, TypeError):
205 # value was not an valid column identifier
206 # i.e. take it as a mathematical expression
207 if replacedollar:
208 m = _columnintref.search(value)
209 while m:
210 value = "%s%s(%s)%s" % (value[:m.start()], columncallback, m.groups()[0], value[m.end():])
211 m = _columnintref.search(value)
212 value = value.replace("$", columncallback)
213 expression = compile(value.strip(), __file__, "eval")
214 context = context.copy()
215 context[columncallback] = self.columncallback
216 if self.orgdata.columns:
217 key, columndata = self.orgdata.columns.items()[0]
218 count = len(columndata)
219 elif self.orgdata.columndata:
220 count = len(self.orgdata.columndata[0])
221 else:
222 count = 0
223 newdata = []
224 for i in xrange(count):
225 self.columncallbackcount = i
226 for key, values in self.orgdata.columns.items():
227 context[key] = values[i]
228 try:
229 newdata.append(eval(expression, _mathglobals, context))
230 except (ArithmeticError, ValueError):
231 newdata.append(None)
232 self.columns[columnname] = newdata
234 if copy:
235 # copy other, non-conflicting column names
236 for columnname, columndata in self.orgdata.columns.items():
237 if not self.columns.has_key(columnname):
238 self.columns[columnname] = columndata
240 self.columnnames = self.columns.keys()
242 def columncallback(self, value):
243 try:
244 return self.orgdata.columndata[value][self.columncallbackcount]
245 except:
246 return self.orgdata.columns[value][self.columncallbackcount]
249 filecache = {}
251 class file(data):
253 defaultcommentpattern = re.compile(r"(#+|!+|%+)\s*")
254 defaultstringpattern = re.compile(r"\"(.*?)\"(\s+|$)")
255 defaultcolumnpattern = re.compile(r"(.*?)(\s+|$)")
257 def splitline(self, line, stringpattern, columnpattern, tofloat=1):
258 """returns a tuple created out of the string line
259 - matches stringpattern and columnpattern, adds the first group of that
260 match to the result and and removes those matches until the line is empty
261 - when stringpattern matched, the result is always kept as a string
262 - when columnpattern matched and tofloat is true, a conversion to a float
263 is tried; when this conversion fails, the string is kept"""
264 result = []
265 # try to gain speed by skip matching regular expressions
266 if line.find('"')!=-1 or \
267 stringpattern is not self.defaultstringpattern or \
268 columnpattern is not self.defaultcolumnpattern:
269 while len(line):
270 match = stringpattern.match(line)
271 if match:
272 result.append(match.groups()[0])
273 line = line[match.end():]
274 else:
275 match = columnpattern.match(line)
276 if tofloat:
277 try:
278 result.append(float(match.groups()[0]))
279 except (TypeError, ValueError):
280 result.append(match.groups()[0])
281 else:
282 result.append(match.groups()[0])
283 line = line[match.end():]
284 else:
285 if tofloat:
286 try:
287 return map(float, line.split())
288 except (TypeError, ValueError):
289 result = []
290 for r in line.split():
291 try:
292 result.append(float(r))
293 except (TypeError, ValueError):
294 result.append(r)
295 else:
296 return line.split()
297 return result
299 def getcachekey(self, *args):
300 return ":".join([str(x) for x in args])
302 def __init__(self, filename,
303 commentpattern=defaultcommentpattern,
304 stringpattern=defaultstringpattern,
305 columnpattern=defaultcolumnpattern,
306 skiphead=0, skiptail=0, every=1,
307 **kwargs):
309 def readfile(file, title, self=self, commentpattern=commentpattern, stringpattern=stringpattern, columnpattern=columnpattern, skiphead=skiphead, skiptail=skiptail, every=every):
310 columns = []
311 columndata = []
312 linenumber = 0
313 maxcolumns = 0
314 for line in file.readlines():
315 line = line.strip()
316 match = commentpattern.match(line)
317 if match:
318 if not len(columndata):
319 columns = self.splitline(line[match.end():], stringpattern, columnpattern, tofloat=0)
320 else:
321 linedata = []
322 for value in self.splitline(line, stringpattern, columnpattern, tofloat=1):
323 linedata.append(value)
324 if len(linedata):
325 if linenumber >= skiphead and not ((linenumber - skiphead) % every):
326 linedata = [linenumber + 1] + linedata
327 if len(linedata) > maxcolumns:
328 maxcolumns = len(linedata)
329 columndata.append(linedata)
330 linenumber += 1
331 if skiptail >= every:
332 skip, x = divmod(skiptail, every)
333 del columndata[-skip:]
334 for i in xrange(len(columndata)):
335 if len(columndata[i]) != maxcolumns:
336 columndata[i].extend([None]*(maxcolumns-len(columndata[i])))
337 return points(columndata, title=title, addlinenumbers=0,
338 **dict([(column, i+1) for i, column in enumerate(columns[:maxcolumns-1])]))
340 try:
341 filename.readlines
342 except:
343 # not a file-like object -> open it
344 cachekey = self.getcachekey(filename, commentpattern, stringpattern, columnpattern, skiphead, skiptail, every)
345 if not filecache.has_key(cachekey):
346 filecache[cachekey] = readfile(open(filename), filename)
347 data.__init__(self, filecache[cachekey], **kwargs)
348 else:
349 data.__init__(self, readfile(filename, "user provided file-like object"), **kwargs)
352 conffilecache = {}
354 class conffile(data):
356 def __init__(self, filename, **kwargs):
357 """read data from a config-like file
358 - filename is a string
359 - each row is defined by a section in the config-like file (see
360 config module description)
361 - the columns for each row are defined by lines in the section file;
362 the option entries identify and name the columns
363 - further keyword arguments are passed to the constructor of data,
364 keyword arguments data and titles excluded"""
366 def readfile(file, title):
367 config = ConfigParser.ConfigParser()
368 config.optionxform = str
369 config.readfp(file)
370 sections = config.sections()
371 sections.sort()
372 columndata = [None]*len(sections)
373 maxcolumns = 1
374 columns = {}
375 for i in xrange(len(sections)):
376 point = [sections[i]] + [None]*(maxcolumns-1)
377 for option in config.options(sections[i]):
378 value = config.get(sections[i], option)
379 try:
380 value = float(value)
381 except:
382 pass
383 try:
384 index = columns[option]
385 except KeyError:
386 columns[option] = maxcolumns
387 point.append(value)
388 maxcolumns += 1
389 else:
390 point[index] = value
391 columndata[i] = point
392 # wrap result into a data instance to remove column numbers
393 result = data(points(columndata, addlinenumbers=0, **columns), title=title)
394 # ... but reinsert sections as linenumbers
395 result.columndata = [[x[0] for x in columndata]]
396 return result
398 try:
399 filename.readlines
400 except:
401 # not a file-like object -> open it
402 if not filecache.has_key(filename):
403 filecache[filename] = readfile(open(filename), filename)
404 data.__init__(self, filecache[filename], **kwargs)
405 else:
406 data.__init__(self, readfile(filename, "user provided file-like object"), **kwargs)
409 cbdfilecache = {}
411 class cbdfile(data):
413 defaultstyles = defaultlines
415 def getcachekey(self, *args):
416 return ":".join([str(x) for x in args])
418 def __init__(self, filename, minrank=None, maxrank=None, **kwargs):
420 class cbdhead:
422 def __init__(self, file):
423 (self.magic,
424 self.dictaddr,
425 self.segcount,
426 self.segsize,
427 self.segmax,
428 self.fill) = struct.unpack("<5i20s", file.read(40))
429 if self.magic != 0x20770002:
430 raise ValueError("bad magic number")
432 class segdict:
434 def __init__(self, file, i):
435 self.index = i
436 (self.segid,
437 self.maxlat,
438 self.minlat,
439 self.maxlong,
440 self.minlong,
441 self.absaddr,
442 self.nbytes,
443 self.rank) = struct.unpack("<6i2h", file.read(28))
445 class segment:
447 def __init__(self, file, sd):
448 file.seek(sd.absaddr)
449 (self.orgx,
450 self.orgy,
451 self.id,
452 self.nstrokes,
453 self.dummy) = struct.unpack("<3i2h", file.read(16))
454 oln, olt = self.orgx, self.orgy
455 self.points = [(olt, oln)]
456 for i in range(self.nstrokes):
457 c1, c2 = struct.unpack("2c", file.read(2))
458 if ord(c2) & 0x40:
459 if c1 > "\177":
460 dy = ord(c1) - 256
461 else:
462 dy = ord(c1)
463 if c2 > "\177":
464 dx = ord(c2) - 256
465 else:
466 dx = ord(c2) - 64
467 else:
468 c3, c4, c5, c6, c7, c8 = struct.unpack("6c", file.read(6))
469 if c2 > "\177":
470 c2 = chr(ord(c2) | 0x40)
471 dx, dy = struct.unpack("<2i", c3+c4+c1+c2+c7+c8+c5+c6)
472 oln += dx
473 olt += dy
474 self.points.append((olt, oln))
475 sd.nstrokes = self.nstrokes
477 def readfile(file, title):
478 h = cbdhead(file)
479 file.seek(h.dictaddr)
480 sds = [segdict(file, i+1) for i in range(h.segcount)]
481 sbs = [segment(file, sd) for sd in sds]
483 # remove jumps at long +/- 180
484 for sd, sb in zip(sds, sbs):
485 if sd.minlong < -150*3600 and sd.maxlong > 150*3600:
486 for i, (lat, long) in enumerate(sb.points):
487 if long < 0:
488 sb.points[i] = lat, long + 360*3600
490 columndata = []
491 for sd, sb in zip(sds, sbs):
492 if ((minrank is None or sd.rank >= minrank) and
493 (maxrank is None or sd.rank <= maxrank)):
494 if columndata:
495 columndata.append((None, None))
496 columndata.extend([(long/3600.0, lat/3600.0)
497 for lat, long in sb.points])
499 result = points(columndata, title=title)
500 result.defaultstyles = self.defaultstyles
501 return result
504 try:
505 filename.readlines
506 except:
507 # not a file-like object -> open it
508 cachekey = self.getcachekey(filename, minrank, maxrank)
509 if not cbdfilecache.has_key(cachekey):
510 cbdfilecache[cachekey] = readfile(open(filename, "rb"), filename)
511 data.__init__(self, cbdfilecache[cachekey], **kwargs)
512 else:
513 data.__init__(self, readfile(filename, "user provided file-like object"), **kwargs)
516 class function(_data):
518 defaultstyles = defaultlines
520 assignmentpattern = re.compile(r"\s*([a-z_][a-z0-9_]*)\s*\(\s*([a-z_][a-z0-9_]*)\s*\)\s*=", re.IGNORECASE)
522 def __init__(self, expression, title=_notitle, min=None, max=None,
523 points=100, context={}):
525 if title is _notitle:
526 self.title = expression
527 else:
528 self.title = title
529 self.min = min
530 self.max = max
531 self.numberofpoints = points
532 self.context = context.copy() # be safe on late evaluations
533 m = self.assignmentpattern.match(expression)
534 if m:
535 self.yname, self.xname = m.groups()
536 expression = expression[m.end():]
537 else:
538 raise ValueError("y(x)=... or similar expected")
539 if context.has_key(self.xname):
540 raise ValueError("xname in context")
541 self.expression = compile(expression.strip(), __file__, "eval")
542 self.columns = {}
543 self.columnnames = [self.xname, self.yname]
545 def dynamiccolumns(self, graph):
546 dynamiccolumns = {self.xname: [], self.yname: []}
548 xaxis = graph.axes[self.xname]
549 from pyx.graph.axis import logarithmic
550 logaxis = isinstance(xaxis.axis, logarithmic)
551 if self.min is not None:
552 min = self.min
553 else:
554 min = xaxis.data.min
555 if self.max is not None:
556 max = self.max
557 else:
558 max = xaxis.data.max
559 if logaxis:
560 min = math.log(min)
561 max = math.log(max)
562 for i in range(self.numberofpoints):
563 x = min + (max-min)*i / (self.numberofpoints-1.0)
564 if logaxis:
565 x = math.exp(x)
566 dynamiccolumns[self.xname].append(x)
567 self.context[self.xname] = x
568 try:
569 y = eval(self.expression, _mathglobals, self.context)
570 except (ArithmeticError, ValueError):
571 y = None
572 dynamiccolumns[self.yname].append(y)
573 return dynamiccolumns
576 class functionlambda(function):
578 def __init__(self, f, min=None, max=None, **kwargs):
579 function.__init__(self, "y(x)=f(x)", context={"f": f}, min=min, max=max, **kwargs)
582 class paramfunction(_data):
584 defaultstyles = defaultlines
586 def __init__(self, varname, min, max, expression, title=_notitle, points=100, context={}):
587 if context.has_key(varname):
588 raise ValueError("varname in context")
589 if title is _notitle:
590 self.title = expression
591 else:
592 self.title = title
593 varlist, expression = expression.split("=")
594 expression = compile(expression.strip(), __file__, "eval")
595 keys = [key.strip() for key in varlist.split(",")]
596 self.columns = dict([(key, []) for key in keys])
597 context = context.copy()
598 for i in range(points):
599 param = min + (max-min)*i / (points-1.0)
600 context[varname] = param
601 values = eval(expression, _mathglobals, context)
602 for key, value in zip(keys, values):
603 self.columns[key].append(value)
604 if len(keys) != len(values):
605 raise ValueError("unpack tuple of wrong size")
606 self.columnnames = self.columns.keys()
609 class paramfunctionlambda(paramfunction):
611 def __init__(self, f, min, max, **kwargs):
612 paramfunction.__init__(self, "t", min, max, "x, y = f(t)", context={"f": f}, **kwargs)
615 class functionxy(_data):
617 defaultstyles = defaultlines
619 assignmentpattern = re.compile(r"\s*([a-z_][a-z0-9_]*)\s*\(\s*([a-z_][a-z0-9_]*)\s*,\s*([a-z_][a-z0-9_]*)\s*\)\s*=", re.IGNORECASE)
621 def __init__(self, expression, title=_notitle, xmin=None, xmax=None, ymin=None, ymax=None,
622 points=10, context={}):
624 if title is _notitle:
625 self.title = expression
626 else:
627 self.title = title
628 self.xmin = xmin
629 self.xmax = xmax
630 self.ymin = ymin
631 self.ymax = ymax
632 self.numberofpoints = points
633 self.context = context.copy() # be safe on late evaluations
634 m = self.assignmentpattern.match(expression)
635 if m:
636 self.zname, self.xname, self.yname = m.groups()
637 expression = expression[m.end():]
638 else:
639 raise ValueError("z(x,y)=... or similar expected")
640 if context.has_key(self.xname):
641 raise ValueError("xname in context")
642 if context.has_key(self.yname):
643 raise ValueError("yname in context")
644 self.expression = compile(expression.strip(), __file__, "eval")
645 self.columns = {}
646 self.columnnames = [self.xname, self.yname, self.zname]
648 def dynamiccolumns(self, graph):
649 dynamiccolumns = {self.xname: [], self.yname: [], self.zname: []}
651 xaxis = graph.axes[self.xname]
652 yaxis = graph.axes[self.yname]
653 from pyx.graph.axis import logarithmic
654 logxaxis = isinstance(xaxis.axis, logarithmic)
655 logyaxis = isinstance(yaxis.axis, logarithmic)
656 if self.xmin is not None:
657 xmin = self.xmin
658 else:
659 xmin = xaxis.data.min
660 if self.xmax is not None:
661 xmax = self.xmax
662 else:
663 xmax = xaxis.data.max
664 if logxaxis:
665 xmin = math.log(xmin)
666 xmax = math.log(xmax)
667 if self.ymin is not None:
668 ymin = self.ymin
669 else:
670 ymin = yaxis.data.min
671 if self.ymax is not None:
672 ymax = self.ymax
673 else:
674 ymax = yaxis.data.max
675 if logyaxis:
676 ymin = math.log(ymin)
677 ymax = math.log(ymax)
678 for i in range(self.numberofpoints):
679 x = xmin + (xmax-xmin)*i / (self.numberofpoints-1.0)
680 if logxaxis:
681 x = math.exp(x)
682 self.context[self.xname] = x
683 for j in range(self.numberofpoints):
684 y = ymin + (ymax-ymin)*j / (self.numberofpoints-1.0)
685 if logyaxis:
686 y = math.exp(y)
687 dynamiccolumns[self.xname].append(x)
688 dynamiccolumns[self.yname].append(y)
689 self.context[self.yname] = y
690 try:
691 z = eval(self.expression, _mathglobals, self.context)
692 except (ArithmeticError, ValueError):
693 z = None
694 dynamiccolumns[self.zname].append(z)
695 return dynamiccolumns
698 class functionxylambda(functionxy):
700 def __init__(self, f, xmin=None, xmax=None, ymin=None, ymax=None, **kwargs):
701 functionxy.__init__(self, "z(x,y)=f(x,y)", context={"f": f}, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, **kwargs)
704 class paramtsfunction(_data):
706 defaultstyles = defaultlines
708 def __init__(self, tname, tmin, tmax, sname, smin, smax, expression, title=_notitle, points=10, context={}):
709 if context.has_key(tname):
710 raise ValueError("tname in context")
711 if context.has_key(sname):
712 raise ValueError("sname in context")
713 if title is _notitle:
714 self.title = expression
715 else:
716 self.title = title
717 varlist, expression = expression.split("=")
718 expression = compile(expression.strip(), __file__, "eval")
719 keys = [key.strip() for key in varlist.split(",")]
720 self.columns = dict([(key, []) for key in keys])
721 context = context.copy()
722 for i in range(points):
723 tparam = tmin + (tmax-tmin)*i / (points-1.0)
724 context[tname] = tparam
725 for j in range(points):
726 sparam = smin + (smax-smin)*j / (points-1.0)
727 context[sname] = sparam
728 values = eval(expression, _mathglobals, context)
729 for key, value in zip(keys, values):
730 self.columns[key].append(value)
731 if len(keys) != len(values):
732 raise ValueError("unpack tuple of wrong size")
733 self.columnnames = self.columns.keys()
736 class paramtsfunctionlambda(paramtsfunction):
738 def __init__(self, f, tmin, tmax, smin, smax, **kwargs):
739 paramtsfunction.__init__(self, "t", tmin, tmax, "s", smin, smax, "x, y, z = f(t,s)", context={"f": f}, **kwargs)