2
\$\begingroup\$

I'm picking up Go, and wrote this simple script to parse a two csv files (spending.csv and savings.csv) to plot savings rate graph.

I would like to hear some generic comments on code readability, libraries I missed out on using, and Go-ness of the code I wrote.

I also used global variable months which I am not very happy about, but I could not find another way to get to the variable from (monthTicks) function which overrides plot.Ticks. Any advise on making this prettier is welcome.

Also, I did not use csv library, nor did I parse the command line arguments - these are two improvements I am aware of.

/*
Plot a savings rate graph using income and spending data exported from mint.com.

This script relies on spending.csv and income.csv being present in the same
directory, and produces savings.png image.
*/
package main

import (
    "bufio"
    "os"
    "strconv"
    "strings"

    "github.com/gonum/plot"
    "github.com/gonum/plot/plotter"
    "github.com/gonum/plot/plotutil"
    "github.com/gonum/plot/vg"
)

var BEFORE_MAX int = 6
var AFTER_MAX int = 6
var TICK_INTERVAL int = 2

var months []string // TODO: Figure out how to pass this to monthTicks.

func main() {
    var income_amounts []float64
    months, income_amounts = processFile("income.csv")
    _, spending_amounts := processFile("spending.csv")

    savings_rates := calculateSavingsRates(income_amounts, spending_amounts)

    drawAndSavePlot(savings_rates, "savings.png")
}

// Do the math and build a list of savings rate with a
// BEFORE_MAX/AFTER_MAX-moving average. The formula for savings rate is (money
// earned - money spent) / money earned.
func calculateSavingsRates(income_amounts, spending_amounts []float64) []float64 {
    var savings_rates, moving_average_rates []float64

    for i := range months {
        earned, spent := income_amounts[i], spending_amounts[i]
        savings_rates = append(savings_rates, (earned-spent)/earned)
    }

    for i := range savings_rates {
        before, after := i-BEFORE_MAX, i+AFTER_MAX
        if before < 0 {
            before = 0
        }
        if after > len(savings_rates)-1 {
            after = len(savings_rates) - 1
        }
        var window_sum float64
        for k := before; k < after; k++ {
            window_sum += savings_rates[k]
        }
        moving_average_rates = append(
            moving_average_rates, window_sum/float64(after-before))
    }
    return moving_average_rates
}

// Create and save a plot of savings rates over time.
func drawAndSavePlot(savings_rates []float64, save_filename string) {
    savings_plot, err := plot.New()
    if err != nil {
        panic(err)
    }
    savings_plot.Title.Text = "Plot of savings rate over time"
    savings_plot.X.Label.Text = "Time"
    savings_plot.X.Tick.Marker = monthTicks{}
    savings_plot.Y.Label.Text = "Savings rate"
    plot_points := preparePlotPoints(savings_rates)
    err = plotutil.AddLinePoints(savings_plot, plot_points)

    err = savings_plot.Save(10*vg.Inch, 10*vg.Inch, save_filename)
    if err != nil {
        panic(err)
    }
}

type monthTicks struct{}

// Verbal month/year (e.g. "Mar 2016") tick labels for the plot. Ticks appears
// every TICK_INTERVAL months.
func (monthTicks) Ticks(min, max float64) []plot.Tick {
    var ticks []plot.Tick
    for i, month := range months {
        if i%TICK_INTERVAL != 0 {
            continue
        }
        split := strings.Split(month, " ")
        month_, year := split[0], split[1]
        month = strings.Join([]string{month_[:3], year}, " ")

        ticks = append(ticks, plot.Tick{Value: float64(i), Label: month})
    }
    return ticks
}

// Create plot points with integers 0..len(savings_rates) on X axis, and a
// given rate plot on Y axis.
func preparePlotPoints(savings_rates []float64) plotter.XYs {
    points := make(plotter.XYs, len(savings_rates))
    for i, rate := range savings_rates {
        points[i].X = float64(i)
        points[i].Y = rate
    }
    return points
}

// Load and parse given csv file, returning a list of first column values
// (months) and a list of second column values ($ amount). First line (headers)
// and last line (totals) are not returned.
func processFile(filename string) ([]string, []float64) {
    file, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    var months []string
    var amounts []float64

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        text := strings.Replace(scanner.Text(), "\"", "", -1)
        text = strings.Replace(text, "$", "", -1)
        var split []string = strings.Split(text, ",")
        month := split[0]
        if month == "Dates" || month == "Total" { // Ignore first/last columns.
            continue
        }

        amount, err := strconv.ParseFloat(strings.Join(split[1:], ""), 64)
        if err != nil {
            panic(err)
        }
        months = append(months, month)
        amounts = append(amounts, amount)
    }

    if err := scanner.Err(); err != nil {
        panic(err)
    }

    return months, amounts
}
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

Some high-level comments after one pass on your code:

  • Don't panic. Handle errors instead; and use log.Fatalf at the highest level.
  • Don't use underscores in variable names. Your variable names are also a bit too long.
  • Declare your variables in one block, don't declare their type unless needed:

    var (
         beforeMax = 6
         afterMax = 6
         …
    )
    

    See also var split []string = strings.Split(text, ",") that should be rewritten split := strings.Split(text, ",").

  • To get your month variable from monthTicks, add it into the struct:

    type monthTicks struct{
        months []string
    }
    

    and initialize it with:

    savingsPlot.X.Tick.Marker = &monthTicks{months}
    

    the Ticks function becomes:

    func (mt *monthTicks) Ticks(min, max float64) []plot.Tick {
        var ticks []plot.Tick
        for i, month := range mt.months {
            …
        }
        …
    }
    
  • Not using command-line flags for a small script for simplicity's sake is questionable, but why not. There's really no excuse for not using the csv package, though — you're making your job significantly more difficult and I'm pretty sure a malformed input could break your code in various ways.
  • Scanner.Scan will fail if your lines are too long. Something to be aware of =)
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.