diff --git a/engine/html.go b/engine/html.go index 671cb98e7..6901f0837 100644 --- a/engine/html.go +++ b/engine/html.go @@ -265,14 +265,16 @@ m = {{.Data.Configs | .SelectArray "mselect" "Uniform"}} {{.TextBox "margs" "(1, -{{.Data.Div "gnuplot"}} +{{.Data.Div "plot"}} +

TableAutosave: {{.TextBox "tableAutoSave" "0" }} s

- Plot of "table.txt", provided table is being autosaved and gnuplot installed.
- plot "table.txt" using {{.TextBox "usingx" "1"}} : {{.TextBox "usingy" "2"}} with lines
-

{{.Span "plotErr" ""}}

- {{.Img "plot" "/plot/"}} +

+ Plot columns {{.TextBox "usingx" "0" "size=1"}} and {{.TextBox "usingy" "1" "size=1"}} of "table.txt". +

+

{{.Span "plotErr" ""}}

+{{.Img "plot" "/plot/"}} diff --git a/engine/plot.go b/engine/plot.go index 20056a9f6..062f3c6e6 100644 --- a/engine/plot.go +++ b/engine/plot.go @@ -3,99 +3,188 @@ package engine import ( "bytes" "errors" - "fmt" - "github.com/mumax/3/httpfs" "image" + "image/color" "image/png" - "io/ioutil" + "io" "net/http" - "os/exec" - "sync/atomic" + "strconv" + "strings" + "sync" + "time" + + "gonum.org/v1/plot" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/vg" + "gonum.org/v1/plot/vg/draw" ) -var nPlots int32 // counts number of active gnuplot processes -const MAX_GNUPLOTS = 5 // maximum allowed number of gnuplot processes +const DefaultCacheLifetime = 1 * time.Second -func (g *guistate) servePlot(w http.ResponseWriter, r *http.Request) { +var guiplot *TablePlot + +// TablePlot is a WriterTo which writes a (cached) plot image of table data. +// The internally cached image will be updated when the column indices have been changed, +// or when the cache lifetime is exceeded. +// If another GO routine is updating the image, the cached image will be written. +type TablePlot struct { + lock sync.Mutex + updating bool - out := []byte{} + table *DataTable + xcol, ycol int - // handle error and return wheter err != nil. - handle := func(err error) bool { - if err != nil { - w.Write(emptyIMG()) - g.Set("plotErr", err.Error()+string(out)) - return true - } else { - return false - } + cache struct { + img []byte // cached output + err error // cached error + expirytime time.Time // expiration time of the cache + lifetime time.Duration // maximum lifetime of the cache } +} - // limit max processes - atomic.AddInt32(&nPlots, 1) - defer atomic.AddInt32(&nPlots, -1) - if atomic.LoadInt32(&nPlots) > MAX_GNUPLOTS { - handle(errors.New("too many gnuplot processes")) - return +func NewPlot(table *DataTable) (p *TablePlot) { + p = &TablePlot{table: table, xcol: 0, ycol: 1} + p.cache.lifetime = DefaultCacheLifetime + return +} + +func (p *TablePlot) SelectDataColumns(xcolidx, ycolidx int) { + p.lock.Lock() + defer p.lock.Unlock() + if xcolidx != p.xcol || ycolidx != p.ycol { + p.xcol, p.ycol = xcolidx, ycolidx + p.cache.expirytime = time.Time{} // this will trigger an update at the next write } +} - a := g.StringValue("usingx") - b := g.StringValue("usingy") +func (p *TablePlot) WriteTo(w io.Writer) (int64, error) { + p.update() - cmd := "gnuplot" - args := []string{"-e", fmt.Sprintf(`set format x "%%g"; set key off; set format y "%%g"; set term svg size 480,320 font 'Arial,10'; plot "-" u %v:%v w li; set output;exit;`, a, b)} - excmd := exec.Command(cmd, args...) + p.lock.Lock() + defer p.lock.Unlock() - stdin, err := excmd.StdinPipe() - if handle(err) { + if p.cache.err != nil { + return 0, p.cache.err + } + nBytes, err := w.Write(p.cache.img) + return int64(nBytes), err +} + +// Updates the cached image if the cache is expired +// Does nothing if the image is already being updated by another GO process +func (p *TablePlot) update() { + p.lock.Lock() + xcol, ycol := p.xcol, p.ycol + needupdate := !p.updating && time.Now().After(p.cache.expirytime) + p.updating = p.updating || needupdate + p.lock.Unlock() + + if !needupdate { return } - stdout, err := excmd.StdoutPipe() - if handle(err) { + // create plot without the TablePlot being locked! + img, err := CreatePlot(p.table, xcol, ycol) + + p.lock.Lock() + p.cache.img, p.cache.err = img, err + p.updating = false + if p.xcol == xcol && p.ycol == ycol { + p.cache.expirytime = time.Now().Add(p.cache.lifetime) + } else { // column indices have been changed during the update + p.cache.expirytime = time.Time{} + } + p.lock.Unlock() +} + +// Returns a png image plot of table data +func CreatePlot(table *DataTable, xcol, ycol int) (img []byte, err error) { + if table == nil { + err = errors.New("DataTable pointer is nil") return } - data, err := httpfs.Read(fmt.Sprintf(`%vtable.txt`, OD())) - if handle(err) { + data, err := table.Read() + if err != nil { return } - err = excmd.Start() - if handle(err) { + header := table.Header() + + if !(xcol >= 0 && xcol < len(header) && ycol >= 0 && ycol < len(header)) { + err = errors.New("Invalid column index") return } - defer excmd.Wait() - _, err = stdin.Write(data) - if handle(err) { + pl, err := plot.New() + if err != nil { return } - err = stdin.Close() - if handle(err) { + + pl.X.Label.Text = header[xcol].Name() + if unit := header[xcol].Unit(); unit != "" { + pl.X.Label.Text += " (" + unit + ")" + } + pl.Y.Label.Text = header[ycol].Name() + if unit := header[ycol].Unit(); unit != "" { + pl.Y.Label.Text += " (" + unit + ")" + } + + pl.X.Label.Font.SetName("Helvetica") + pl.Y.Label.Font.SetName("Helvetica") + pl.X.Label.Padding = 0.2 * vg.Inch + pl.Y.Label.Padding = 0.2 * vg.Inch + + points := make(plotter.XYs, len(data)) + for i := 0; i < len(data); i++ { + points[i].X = data[i][xcol] + points[i].Y = data[i][ycol] + } + + scatter, err := plotter.NewScatter(points) + if err != nil { return } + scatter.Color = color.RGBA{R: 255, G: 0, B: 0, A: 255} + scatter.Shape = draw.CircleGlyph{} + scatter.Radius = 1 + pl.Add(scatter) - out, err = ioutil.ReadAll(stdout) - if handle(err) { + wr, err := pl.WriterTo(8*vg.Inch, 4*vg.Inch, "png") + if err != nil { return } - w.Header().Set("Content-Type", "image/svg+xml") - w.Write(out) - g.Set("plotErr", "") - return + buf := bytes.NewBuffer(nil) + _, err = wr.WriteTo(buf) + if err != nil { + return nil, err + } else { + return buf.Bytes(), nil + } } -var empty_img []byte +func (g *guistate) servePlot(w http.ResponseWriter, r *http.Request) { + if guiplot == nil { + guiplot = NewPlot(&Table) + } + + xcol, errx := strconv.Atoi(strings.TrimSpace(g.StringValue("usingx"))) + ycol, erry := strconv.Atoi(strings.TrimSpace(g.StringValue("usingy"))) + if errx != nil || erry != nil { + guiplot.SelectDataColumns(-1, -1) // set explicitly invalid column indices + } else { + guiplot.SelectDataColumns(xcol, ycol) + } + + w.Header().Set("Content-Type", "image/png") + _, err := guiplot.WriteTo(w) -// empty image to show if there's no plot... -func emptyIMG() []byte { - if empty_img == nil { - o := bytes.NewBuffer(nil) - png.Encode(o, image.NewNRGBA(image.Rect(0, 0, 4, 4))) - empty_img = o.Bytes() + if err != nil { + png.Encode(w, image.NewNRGBA(image.Rect(0, 0, 4, 4))) + g.Set("plotErr", "Plot Error: "+err.Error()) + } else { + g.Set("plotErr", "") } - return empty_img } diff --git a/engine/table.go b/engine/table.go index 6b8c577e3..0577e4370 100644 --- a/engine/table.go +++ b/engine/table.go @@ -1,16 +1,22 @@ package engine import ( + "bytes" + "encoding/csv" + "errors" "fmt" + "io" + "strconv" + "strings" + "sync" + "time" + "github.com/mumax/3/cuda" "github.com/mumax/3/data" "github.com/mumax/3/httpfs" "github.com/mumax/3/script" "github.com/mumax/3/timer" "github.com/mumax/3/util" - "io" - "sync" - "time" ) var Table = *newTable("table") // output handle for tabular data (average magnetization etc.) @@ -107,6 +113,39 @@ func (t *DataTable) Add(output Quantity) { t.outputs = append(t.outputs, output) } +type ColumnHeader struct { + name string + unit string +} + +func (c ColumnHeader) String() string { + return c.name + " (" + c.unit + ")" +} + +func (c ColumnHeader) Name() string { + return c.name +} + +func (c ColumnHeader) Unit() string { + return c.unit +} + +func (t *DataTable) Header() (headers []ColumnHeader) { + headers = make([]ColumnHeader, 0) + headers = append(headers, ColumnHeader{"t", "s"}) + for _, o := range t.outputs { + if o.NComp() == 1 { + headers = append(headers, ColumnHeader{NameOf(o), UnitOf(o)}) + } else { + for c := 0; c < o.NComp(); c++ { + name := NameOf(o) + string('x'+c) + headers = append(headers, ColumnHeader{name, UnitOf(o)}) + } + } + } + return +} + func (t *DataTable) Save() { t.flushlock.Lock() // flush during write gives errShortWrite defer t.flushlock.Unlock() @@ -131,6 +170,49 @@ func (t *DataTable) Save() { } } +func (t *DataTable) Read() (data [][]float64, err error) { + if !t.inited() { + return nil, errors.New("Table is not initialized") + } + + t.flush() + t.flushlock.Lock() + defer t.flushlock.Unlock() + + rawdata, err := httpfs.Read(fmt.Sprintf(`%v%s.txt`, OD(), t.name)) + if err != nil { + return + } + + csvReader := csv.NewReader(bytes.NewReader(rawdata)) + csvReader.Comma = '\t' + csvReader.Comment = '#' + + nCols := len(t.Header()) + data = make([][]float64, 0) + + for { + line, err := csvReader.Read() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + + record := make([]float64, nCols) + for c, _ := range record { + record[c], err = strconv.ParseFloat(strings.TrimSpace(line[c]), 64) + if err != nil { + return nil, err + } + } + + data = append(data, record) + } + + return data, nil +} + func (t *DataTable) Println(msg ...interface{}) { t.init() fprintln(t, msg...) @@ -150,15 +232,10 @@ func (t *DataTable) init() { t.output = f // write header - fprint(t, "# t (s)") - for _, o := range t.outputs { - if o.NComp() == 1 { - fprint(t, "\t", NameOf(o), " (", UnitOf(o), ")") - } else { - for c := 0; c < o.NComp(); c++ { - fprint(t, "\t", NameOf(o)+string('x'+c), " (", UnitOf(o), ")") - } - } + header := t.Header() + fprint(t, "# ", header[0]) + for col := 1; col < len(header); col++ { + fprint(t, "\t", header[col]) } fprintln(t) t.Flush()