summary history branches tags files
commit:0f011ac5dbd7d6230463a8c2e4cd5eb9488bf937
author:Trevor Bentley
committer:Trevor Bentley
date:Wed Aug 28 19:02:20 2024 +0200
parents:650684ab17ba0cf9cc6e6914d7faeacd720fde4c
rewrite to use tables as internal data format

* add support for selecting absolute or relative values
* add support for arbitrary independent column/row stops and fractions
* add support for exporting CSV as formulas instead of values
diff --git a/src/main.rs b/src/main.rs
line changes: +286/-290
index 2bf86b3..ac78dde
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,91 +1,6 @@
-//! # Introduction
-//!
 //! **fstop-print-calc** calculates times relative to a base time
 //! using fractions of f-stops.
 //!
-//! # F-stop printing
-//!
-//! F-stop printing refers to the concept of working in fractions of
-//! an f-stop when enlarging photographic prints, rather than linear
-//! time.
-//!
-//! Instead of making a test strip of 5, 10, 15, 20, and 25 second
-//! increments, you might make a strip in +0, +1/3, +2/3, +1, and +4/3
-//! *stops* relative to a 5s base time.
-//!
-//! The primary advantages are that test strips are created in
-//! standardized units, and a print documented in terms of f-stops can
-//! be reproduced accurately with different lenses, aperatures, and
-//! sizes.
-//!
-//!
-//! # The calculator
-//!
-//! For the most basic usage, simply run the program with a base time
-//! and it will show +/- 3 stops in 1/3rd stop increments:
-//!
-//! `$ fstop-print-calc 16`
-//!
-//! Instead of calculating a range of values, you can calculate one
-//! specific value by specifying it in the format `+X/Y` or `-X/Y`.
-//! To see +5/3rds stop:
-//!
-//! `$ fstop-print-calc 16 -- +5/3`
-//!
-//! You can specify the number of stops to display in each direction
-//! with `--stops`.  This will show from -5 to +5 stops from the base:
-//!
-//! `$ fstop-print-calc 16 --stops 5`
-//!
-//! You can specify the fraction of stops to use with `--fraction`.
-//! This will show +/- 3 stops in 1/4th stop steps:
-//!
-//! `$ fstop-print-calc 16 --fraction 4`
-//!
-//! You can specify `--fraction` several times, too.  This shows 1/4,
-//! 1/3, and 1/2 steps combined:
-//!
-//! `$ fstop-print-calc 16 --fraction 2 --fraction 3 --fraction 4`
-//!
-//! You can output the data in CSV format:
-//!
-//! `$ fstop-print-calc 16 --csv`
-//!
-//! Or you can output an entire CSV table, with each row being offset
-//! from the next and previous row by an f-stop fraction.  Generate a
-//! +/-3 stop table in 1/3rd stop increments (19x19 table), with:
-//!
-//! `$ fstop-print-calc 16 --stops 3 --csv --csv-stops 3 --csv-fraction 3`
-//!
-//!
-//! # The math
-//!
-//! Fractional stops work the same as a compounding growth equation,
-//! where multiplying the base time (b) by some growth factor (x)
-//! repeatedly (N) times doubles the base time (1 stop increase).  So:
-//!
-//! ```
-//! b*x^N = 2*b
-//!  => x^N = 2 [base cancels]
-//!  => x = Nth_root(2) [Nth root of both sides]
-//!  => x = 2^(1/N) [alternate equivalent form]
-//! ```
-//!
-//! N is the fraction of a stop, and calculating for x finds an
-//! exponential growth factor.
-//!
-//! An example for 1/3rd stop increments:
-//!
-//! * N is 3 (denominator of 1/3)
-//! * growth factor x calculated as x = 2^(1/3) = 1.25992
-//! * base b chosen to be 10 seconds
-//! * b + 1/3rd stop = 10 * 1.25992^1 = 12.599s
-//! * b + 2/3rd stop = 10 * 1.25992^2 = 15.874s
-//! * b + 3/3rd stop = 10 * 1.25992^3 = 20.000s
-//!
-//! This works for any fraction, and for positive or negative exponents.
-//!
-//!
 // # License (AGPL Version 3.0)
 //
 // fstop-print-calc - an f-stop enlarging time calculator
@@ -107,9 +22,9 @@
 use clap::Parser;
 use clap_num::number_range;
 
-const MAX_DENOMINATOR: usize = 16;
+const MAX_DENOMINATOR: u32 = 16;
 
-fn frac_range(s: &str) -> Result<usize, String> {
+fn frac_range(s: &str) -> Result<u32, String> {
     number_range(s, 1, MAX_DENOMINATOR)
 }
 
@@ -125,261 +40,342 @@ struct CalcArgs {
 
     /// Stop fraction to calculate (1-16).  Can be provided multiple times (default: 3 i.e. 1/3rd stop)
     #[arg(short, long, value_parser=frac_range)]
-    fraction: Vec<usize>,
+    fraction: Vec<u32>,
 
     /// Number of whole stops to calculate in each direction (default: 3)
     #[arg(short, long)]
-    stops: Option<i32>,
+    stops: Option<u32>,
 
     /// Round to this many decimal digits (default: 1)
     #[arg(short, long)]
     precision: Option<usize>,
 
+    /// Show times relative to base instead of absolute
+    #[arg(short, long)]
+    relative: bool,
+
     /// Output in CSV format.  Single row by default, or table if csv-fraction and csv-stops specified.
     #[arg(short, long)]
     csv: bool,
 
-    /// Stop fraction to use for CSV table. Only one permitted (default: 3 i.e. 1/3rd stop)
+    /// Output CSV with formulas instead of values
     #[arg(long)]
-    csv_fraction: Option<usize>,
+    csv_formulas: bool,
 
-    /// Number of whole stops to output in CSV table.
+    /// Output CSV without header row and column
     #[arg(long)]
-    csv_stops: Option<usize>,
-}
+    csv_skip_header: bool,
+
+    /// Fractions to use for rows if generating a table (e.g. CSV)
+    #[arg(long)]
+    table_fraction: Vec<u32>,
 
-fn fstop_calc(base: f64, num: f64, denom: f64, sign: f64) -> f64 {
-    // This is a compounding growth equation, where multiplying the
-    // base time (b) by some growth factor (x) repeatedly (N) times
-    // doubles the base time (1 stop increase).  So:
-    //
-    // b*x^N = 2*b
-    //  => x^N = 2 [base cancels]
-    //  => x = Nth_root(2) [Nth root of both sides]
-    //  => x = 2^(1/N) [alternate equivalent form]
-    //
-    // N is the fraction of a stop, and calculating for x finds an
-    // exponential growth factor.
-    let growth: f64 = (2_f64).powf(1./denom as f64);
-
-    let multiplier: f64 = (sign as i64 * num as i64 / denom as i64) as f64;
-    let modulo: f64 = (num % denom) as f64;
-    base*growth.powf(multiplier*denom + sign * modulo)
+    /// Number of stops to use for rows if generating a table (e.g. CSV) (default: 3)
+    #[arg(long)]
+    table_stops: Option<u32>,
+}
+impl CalcArgs {
+    pub fn precision(&self) -> usize {
+        self.precision.unwrap_or(1)
+    }
 }
 
-fn fstop_offset_str(num: f64, denom: f64, sign: f64, indent: usize, modulo: bool) -> String {
-    let sign_str = match sign {
-        -1.0 => "-",
-        _ => "+",
+// Given a list of integer denominators (2, 3, 4,...), return a
+// sorted list of fractions between 0 and 1 with any equivalents
+// removed (1/4, 1/3, 1/2, 2/3, 3/4,...)
+fn denominators_to_fractions(denominators: &Vec<u32>) -> Vec<Fraction> {
+    // default to 1/3rd of a stop
+    let denominators = match denominators.len() {
+        0 => vec!(3),
+        _ => denominators.clone(),
     };
-    if denom == 1.0 {
-        let mult: f64 = (sign as i64 * num as i64 / denom as i64) as f64;
-        format!("{}{}", sign_str, mult.abs())
-    }
-    else {
-        let num = match modulo {
-            false => num,
-            true => (num % denom) as f64,
-        };
-        format!("{}{}{}/{}", " ".repeat(indent), sign_str, num, denom)
+
+    // build a list of all possible fractions
+    let mut fractions: Vec<Fraction> = vec!();
+    for denom in &denominators {
+        for num in 1..*denom {
+            fractions.push(Fraction::new(num as f64, *denom as f64));
+        }
     }
-}
 
-fn fstop_strings(base: f64, num: f64, denom: f64, sign: f64, indent: usize, p: usize, modulo: bool) -> (String, String) {
-    let off_str = fstop_offset_str(num, denom, sign, indent, modulo);
-    let val = fstop_calc(base, num, denom, sign);
-    (off_str, format!("{:.p$}", val, p=p))
-}
+    // sort fractions
+    fractions.sort_by(|a, b| {
+        let fa: f64 = a.num() / a.denom();
+        let fb: f64 = b.num() / b.denom();
+        fa.partial_cmp(&fb).expect("Could not compare fractions.")
+    });
 
-fn fstop_print(base: f64, num: f64, denom: f64, sign: f64, indent: usize, p: usize, modulo: bool) {
-    let (offs, val) = fstop_strings(base, num, denom, sign, indent, p, modulo);
-    println!("{}: {}", offs, val);
+    // remove duplicates (ex: 1/2 == 2/4 == 3/6)
+    fractions.dedup_by(|a, b| {
+        let fa: f64 = a.num() / a.denom();
+        let fb: f64 = b.num() / b.denom();
+        fa == fb
+    });
+
+    // always include 1/1
+    fractions.push(Fraction::new(1., 1.));
+    fractions
 }
 
-fn print_csv_header(base: f64, stops: i32, fractions: &Vec<(u32, u32)>, p: usize) {
-    // offset header (negative)
-    for i in (0..stops).rev() {
-        let (i, sign) = (-i as f64, -1.0);
-        for (num, denom) in fractions.iter().rev() {
-            let num = *num as f64 + (-i as f64 * *denom as f64);
-            let (offs, _val) = fstop_strings(base, num, *denom as f64, sign, 0, p, true);
-            print!("'{offs}',");
+// Given a list of fractions (ex: 1/2, 1/3) and stops (ex: 2), return
+// an expanded list of fractions covering the entire span in both
+// directions (ex: -2, ..., -4/3, -1, -2/3, -1/2, -1/3, 0, 1/3, 1/2,
+// ..., 2)
+fn fractions_to_steps(fractions: &Vec<Fraction>, stops: u32) -> Vec<Fraction> {
+    // expand fractions out to the positive stops
+    let mut col_steps: Vec<Fraction> = match stops {
+        0 => vec!(),
+        _ => fractions.clone(),
+    };
+    for stop in 1..stops {
+        for frac in &fractions.clone() {
+            col_steps.push(Fraction::new(frac.num() + (frac.denom() * stop as f64), frac.denom()));
         }
     }
+    // duplicate in the negative direction around a (0,1) base point
+    let col_steps: Vec<Fraction> = col_steps.clone().iter().rev().map(|x| x.inverted())
+        .chain(vec!(Fraction::new(0., 1.)))
+        .chain(col_steps).collect();
 
-    // offset header (base)
-    let (offs, _val) = fstop_strings(base, 0.0, 1.0, 1.0, 0, p, true);
-    print!("'{offs}',");
-
-    // offset header (positive)
-    for i in 0..stops {
-        let (i, sign) = (i as f64, 1.0);
-        for (num, denom) in fractions {
-            let num = *num as f64 + (i as f64 * *denom as f64);
-            let (offs, _val) = fstop_strings(base, num, *denom as f64, sign, 0, p, true);
-            print!("'{offs}',");
-        }
-    }
-    println!();
+    col_steps
 }
 
-fn print_csv_row(base: f64, stops: i32, fractions: &Vec<(u32, u32)>, p: usize) {
-    // values (negative)
-    for i in (0..stops).rev() {
-        let (i, sign) = (-i as f64, -1.0);
-        for (num, denom) in fractions.iter().rev() {
-            let num = *num as f64 + (-i as f64 * *denom as f64);
-            let (_offs, val) = fstop_strings(base, num, *denom as f64, sign, 0, p, true);
-            print!("{val},");
+#[derive(Debug, Clone)]
+struct Fraction {
+    numerator: f64,
+    denominator: f64,
+    sign: f64,
+}
+impl Fraction {
+    pub fn new(num: f64, denom: f64) -> Fraction {
+        let sign = match num {
+            0.0 => 1.0,
+            _ => num / num.abs(),
+        };
+        let num = num.abs();
+        Fraction {
+            numerator: num,
+            denominator: denom,
+            sign,
         }
     }
-
-    // values (base)
-    let (_offs, val) = fstop_strings(base, 0.0, 1.0, 1.0, 0, p, true);
-    print!("{val},");
-
-    // values (positive)
-    for i in 0..stops {
-        let (i, sign) = (i as f64, 1.0);
-        for (num, denom) in fractions {
-            let num = *num as f64 + (i as f64 * *denom as f64);
-            let (_offs, val) = fstop_strings(base, num, *denom as f64, sign, 0, p, true);
-            print!("{val},");
+    pub fn modulo(&self) -> f64 {
+        (self.numerator % self.denominator) as f64
+    }
+    pub fn inverted(&self) -> Fraction {
+        Fraction {
+            numerator: self.num(),
+            denominator: self.denom(),
+            sign: -1.0 * self.sign,
+        }
+    }
+    pub fn num(&self) -> f64 {
+        self.numerator * self.sign
+    }
+    pub fn denom(&self) -> f64 {
+        self.denominator
+    }
+    pub fn offset_str(&self, indent: usize, modulo: bool) -> String {
+        if self.denominator == 1.0 {
+            let mult: f64 = (self.sign as i64 * self.numerator as i64 / self.denominator as i64) as f64;
+            format!("{:+}", mult)
+        }
+        else {
+            let num = match modulo {
+                false => self.numerator,
+                true => (self.numerator % self.denominator) as f64,
+            };
+            format!("{}{:+}/{}", " ".repeat(indent), self.sign * num, self.denominator)
         }
     }
-    println!();
 }
 
-fn print_csv_table(base: f64, stops: i32, fractions: &Vec<(u32, u32)>, csv_stops: &Option<usize>, csv_fraction: &Option<usize>, p: usize) {
-    // If both stops and fraction specified, output a full table
-    match (csv_stops, csv_fraction) {
-        (Some(csv_stops), Some(csv_fraction)) => {
-            let csv_stops = *csv_stops as i32;
-            let csv_fraction = *csv_fraction as u32;
-            let mut csv_fractions: Vec<(u32, u32)> = vec!();
-            for num in 1..csv_fraction {
-                csv_fractions.push((num as u32, csv_fraction));
-            }
-            csv_fractions.push((1, 1));
-
-            print_csv_header(base, csv_stops, &csv_fractions, p);
-
-            // calculate and print all of the negative and +0 rows
-            let tmp_base = fstop_calc(base, -stops as f64 * csv_fraction as f64, csv_fraction as f64, 1.0);
-            print_csv_row(tmp_base, csv_stops, &csv_fractions, p);
-            for i in (0..stops).rev() {
-                for j in (0..csv_fraction).rev() {
-                    let num = (i as f64 * csv_fraction as f64) + j as f64;
-                    let tmp_base = fstop_calc(base, num, csv_fraction as f64, -1.0);
-                    print_csv_row(tmp_base, csv_stops, &csv_fractions, p);
-                }
-            }
-
-            // calculate and print all of the positive rows
-            for i in 0..stops {
-                for j in 0..csv_fraction {
-                    if i == 0 && j == 0 {
-                        continue; // don't duplicate base
-                    }
-                    let num = (i as f64 * csv_fraction as f64) + j as f64;
-                    let tmp_base = fstop_calc(base, num, csv_fraction as f64, 1.0);
-                    print_csv_row(tmp_base, csv_stops, &csv_fractions, p);
-                }
-            }
-            let tmp_base = fstop_calc(base, stops as f64 * csv_fraction as f64, csv_fraction as f64, 1.0);
-            print_csv_row(tmp_base, csv_stops, &csv_fractions, p);
-        },
-        _ => {
-            // output a single row
-            print_csv_header(base, stops, fractions, p);
-            print_csv_row(base, stops, fractions, p);
-        },
+struct FstopEntry {
+    base: f64,       // relative to this base value
+    frac: Fraction,  // this f-stop fraction
+}
+impl FstopEntry {
+    pub fn new(base: f64, num: f64, denom: f64) -> FstopEntry {
+        FstopEntry {
+            base,
+            frac: Fraction::new(num, denom),
+        }
+    }
+    pub fn multiplier(&self) -> f64 {
+        (self.frac.num() as i64 / self.frac.denom() as i64) as f64
+    }
+    pub fn growth(&self) -> f64 {
+        (2_f64).powf(1./self.frac.denom())
+    }
+    pub fn abs(&self) -> f64 {
+        let growth = self.base * self.growth().powf(self.multiplier() * self.frac.denominator + self.frac.sign * self.frac.modulo());
+        match growth.is_nan() {
+            true => self.base,
+            _ => self.base * self.growth().powf(self.multiplier() * self.frac.denominator + self.frac.sign * self.frac.modulo()),
+        }
+    }
+    pub fn abs_str(&self, precision: usize) -> String {
+        format!("{:.p$}", self.abs(), p=precision)
+    }
+    pub fn rel(&self) -> f64 {
+        self.abs() - self.base
+    }
+    pub fn rel_str(&self, precision: usize) -> String {
+        format!("{:+.p$}", self.rel(), p=precision)
+    }
+    pub fn offset_str(&self, indent: usize, modulo: bool) -> String {
+        self.frac.offset_str(indent, modulo)
+    }
+    pub fn str(&self, precision: usize, relative: bool) -> String {
+        match (relative, self.frac.num() == 0.0) {
+            (true, false) => self.rel_str(precision),
+            _ => self.abs_str(precision),
+        }
+    }
+    pub fn formula_str(&self, cell: String, relative: bool) -> String {
+        let growth = self.base * self.growth().powf(self.multiplier() * self.frac.denominator + self.frac.sign * self.frac.modulo());
+        let rel_str = match relative {
+            true => format!(" - {}", cell),
+            _ => format!(""),
+        };
+        match growth.is_nan() {
+            true => format!("{}{}", self.base, rel_str),
+            _ => format!("={} * POWER({}, {} * {} + {} * {}){}", cell, self.growth(), self.multiplier(), self.frac.denom(), self.frac.sign, self.frac.modulo(), rel_str),
+        }
     }
 }
 
-fn main() {
-    let cli: CalcArgs = CalcArgs::parse();
-
-    // options specified at command line
-    let base = cli.time;
-    let stops = cli.stops.unwrap_or(3);
-    let p = cli.precision.unwrap_or(1);
+// generate a table of all f-stop fractions and their calculated times
+fn fstop_table(cli: &CalcArgs) -> (Vec<Fraction>, Vec<Fraction>, Vec<FstopEntry>) {
+    let col_steps = {
+        let fractions = denominators_to_fractions(&cli.fraction);
+        fractions_to_steps(&fractions, cli.stops.unwrap_or(3))
+    };
 
-    // default to 1/3rd of a stop
-    let enabled_fracs = match cli.fraction.len() {
-        0 => vec!(3),
-        _ => cli.fraction,
+    let row_steps = match cli.csv {
+        true => {
+            let fractions = denominators_to_fractions(&cli.table_fraction);
+            fractions_to_steps(&fractions, cli.table_stops.unwrap_or(3))
+        },
+        _ => vec!(Fraction::new(0., 1.)),
     };
 
-    if let Some(stop) = cli.stop {
-        // parse string manually:
-        let (sign_str, fraction) = stop.split_at(1);
-        let sign: f64 = match sign_str {
-            "+" => { 1. },
-            "-" => { -1. },
-            _ => panic!("Stop missing +/- sign.  Format must be +X/Y or -X/Y."),
-        };
-        let slash_offs = fraction.find("/").expect("Stop missing fractional slash.  Format must be +X/Y or -X/Y.");
-        let (num, denom) = fraction.split_at(slash_offs);
-        let denom: &str = &denom[1..];
-        let _ = frac_range(denom).expect(&format!("Stop denominator not in range 1-{}.  Format must be +X/Y or -X/Y.", MAX_DENOMINATOR));
-        let num: f64 = num.parse::<u32>().expect("Stop numerator not an integer.  Format must be +X/Y or -X/Y.") as f64;
-        let denom: f64 = denom.parse::<u32>().expect("Stop numerator not an integer.  Format must be +X/Y or -X/Y.") as f64;
-
-        // calculate and print result
-        fstop_print(base, num, denom, sign, 0, p, false);
-        return;
+    let mut table: Vec<FstopEntry> = vec!();
+    for row_frac in &row_steps {
+        let row_base = FstopEntry::new(cli.time, row_frac.num(), row_frac.denom());
+        for col_frac in &col_steps {
+            let col_base = FstopEntry::new(row_base.abs(), col_frac.num(), col_frac.denom());
+            table.push(col_base);
+        }
     }
+    (col_steps, row_steps, table)
+}
 
-    // build a list of all possible fractions
-    let mut fractions: Vec<(u32, u32)> = vec!();
-    for denom in &enabled_fracs {
-        for num in 1..*denom {
-            fractions.push((num as u32, *denom as u32));
+// print an f-stop table in CSV format
+fn fstop_table_csv(col_steps: &Vec<Fraction>, row_steps: &Vec<Fraction>, table: &Vec<FstopEntry>, cli: &CalcArgs) {
+    if !cli.csv_skip_header {
+        print!(",");
+        for col_frac in col_steps {
+            print!("\"{}\",", col_frac.offset_str(0, true));
         }
+        println!();
     }
+    for (row, row_frac) in row_steps.iter().enumerate() {
+        if !cli.csv_skip_header {
+            print!("\"{}\",", row_frac.offset_str(0, true));
+        }
+        for (col, _col_frac) in col_steps.iter().enumerate() {
+            print!("\"{}\",", table[row*col_steps.len() + col].str(cli.precision(), cli.relative));
+        }
+        println!();
+    }
+}
 
-    // sort fractions
-    fractions.sort_by(|a, b| {
-        let fa: f64 = a.0 as f64 / a.1 as f64;
-        let fb: f64 = b.0 as f64 / b.1 as f64;
-        fa.partial_cmp(&fb).expect("Could not compare fractions.")
-    });
-
-    // remove duplicates (ex: 1/2 == 2/4 == 3/6)
-    fractions.dedup_by(|a, b| {
-        let fa: f64 = a.0 as f64 / a.1 as f64;
-        let fb: f64 = b.0 as f64 / b.1 as f64;
-        fa == fb
-    });
-
-    // always include 1/1
-    fractions.push((1, 1));
-
+// print an f-stop table in CSV format with formulas
+fn fstop_table_formula_csv(col_steps: &Vec<Fraction>, row_steps: &Vec<Fraction>, table: &Vec<FstopEntry>, cli: &CalcArgs) {
+    let col_char = match cli.csv_skip_header {
+        true => char::from_u32(('A' as usize + (col_steps.len() / 2)) as u32).unwrap_or('A'),
+        _ => char::from_u32(('A' as usize + (col_steps.len() / 2) + 1) as u32).unwrap_or('B'),
+    };
+    let row_idx = match cli.csv_skip_header {
+        true => (row_steps.len() / 2) + 1,
+        _ => (row_steps.len() / 2) + 1 + 1,
+    };
 
-    if cli.csv {
-        print_csv_table(base, stops, &fractions, &cli.csv_stops, &cli.csv_fraction, p);
+    if !cli.csv_skip_header {
+        print!(",");
+        for col_frac in col_steps {
+            print!("\"{}\",", col_frac.offset_str(0, true));
+        }
+        println!();
     }
-    else {
-        // calculate & print negative values
-        for i in (0..stops).rev() {
-            let (i, sign) = (-i as f64, -1.0);
-            for (num, denom) in fractions.iter().rev() {
-                let num = *num as f64 + (-i as f64 * *denom as f64);
-                fstop_print(base, num, *denom as f64, sign, 2, p, true);
+    for (row, row_frac) in row_steps.iter().enumerate() {
+        if !cli.csv_skip_header {
+            print!("\"{}\",", row_frac.offset_str(0, true));
+        }
+        for (col, _col_frac) in col_steps.iter().enumerate() {
+            match col == col_steps.len() / 2 {
+                true => match row == row_steps.len() / 2 {
+                    // middle column and middle row == base point, insert literal value
+                    true => print!("\"{}\",", table[row*col_steps.len() + col].str(cli.precision(), false)),
+                    false => {
+                        // make a new entry relative to the row offset
+                        let entry = FstopEntry::new(table[row*col_steps.len() + col].base, row_frac.num(), row_frac.denom());
+                        // base everything around the center-most field
+                        let base_cell = format!("{}${}", col_char, row_idx);
+                        print!("\"{}\",", entry.formula_str(base_cell, false));
+                    },
+                },
+                false => {
+                    // row is 0-indexed, spreadsheets are 1-indexed
+                    let base_cell = format!("${}{}", col_char, row + match cli.csv_skip_header { true => 1, _ => 2});
+                    print!("\"{}\",", table[row*col_steps.len() + col].formula_str(base_cell, cli.relative));
+                }
             }
         }
+        println!();
+    }
+}
 
-        // base
-        println!("+0: {:.p$}", base as f64, p=p);
+// print a single-rowed f-stop table for the terminal
+fn fstop_table_print_row(col_steps: &Vec<Fraction>, table: &Vec<FstopEntry>, cli: &CalcArgs) {
+    for idx in 0..col_steps.len() {
+        println!("{}: {}", table[idx].offset_str(2, true), table[idx].abs_str(cli.precision()));
+    }
+}
+
+fn main() {
+    let cli: CalcArgs = CalcArgs::parse();
 
-        // calculate & print positive values
-        for i in 0..stops {
-            let (i, sign) = (i as f64, 1.0);
-            for (num, denom) in &fractions {
-                let num = *num as f64 + (i as f64 * *denom as f64);
-                fstop_print(base, num, *denom as f64, sign, 2, p, true);
+    match &cli.stop {
+        Some(stop) => {
+            // parse string manually:
+            let (sign_str, fraction) = stop.split_at(1);
+            let sign: f64 = match sign_str {
+                "+" => { 1. },
+                "-" => { -1. },
+                _ => panic!("Stop missing +/- sign.  Format must be +X/Y or -X/Y."),
+            };
+            let slash_offs = fraction.find("/").expect("Stop missing fractional slash.  Format must be +X/Y or -X/Y.");
+            let (num, denom) = fraction.split_at(slash_offs);
+            let denom: &str = &denom[1..];
+            let _ = frac_range(denom).expect(&format!("Stop denominator not in range 1-{}.  Format must be +X/Y or -X/Y.", MAX_DENOMINATOR));
+            let num: f64 = num.parse::<u32>().expect("Stop numerator not an integer.  Format must be +X/Y or -X/Y.") as f64;
+            let denom: f64 = denom.parse::<u32>().expect("Stop numerator not an integer.  Format must be +X/Y or -X/Y.") as f64;
+
+            // calculate and print result
+            let result = FstopEntry::new(cli.time, sign * num, denom);
+            println!("{}: {}", result.offset_str(0, false), result.str(cli.precision(), cli.relative))
+        },
+        _ => {
+            let (col_steps, row_steps, table) = fstop_table(&cli);
+            match row_steps.len() {
+                1 => fstop_table_print_row(&col_steps, &table, &cli),
+                _ => match cli.csv_formulas {
+                    true => fstop_table_formula_csv(&col_steps, &row_steps, &table, &cli),
+                    _ => fstop_table_csv(&col_steps, &row_steps, &table, &cli),
+                },
             }
-        }
+        },
     }
 }