import numpy as np


############################################################
# np.arange() and np.linspace()
############################################################


# In class last Wednesday, we explained how modules work and what
# happens when we import the `matplotlib.pyplot` module. This enabled us
# to plot data on figures using `plt.plot()` or `plt.hist()`. In a few
# places, we had also used `np.linspace()`, which comes from the `numpy`
# module we had imported as `np`. Calling `np.linspace()` creates a
# sequence of evenly spaced values, which we had used to evaluate the
# density of various probability distributions for plotting.

# `np.linspace()` is closely related to `np.arange()`, which in turn
# generalizes Python's built-in `range()` to allow floats. However,
# while we specify an (optional) step value to `np.arange()` just like
# we do for `range()`, `np.linspace()` expects an optional `num`
# parameter, which says how many values should be in the sequence. Also,
# `np.linspace()` includes the `stop` value in its final output.

# Here are a few examples of `np.arange()` and `np.linspace()` outputs:


print()
print(list(range(10, 20)))
print(np.arange(10, 20))
print(np.arange(10, 20, step=1.5))
print(np.linspace(10, 19, num=10))
print(np.linspace(10, 19, num=7))


# And here is their official documentation:
#   https://numpy.org/doc/stable/reference/generated/numpy.arange.html
#   https://numpy.org/doc/stable/reference/generated/numpy.linspace.html


############################################################
# converting to types we know
############################################################


# When you run the above code, you'll notice that the outputs of
# `np.arange()` and `np.linspace()` are not displayed quite like Python
# lists. This is because `numpy` defines its own types of numbers and
# sequences, which print somewhat differently. (Most sequences produced
# by `numpy` functions are of an "array" type, which in certain
# situations, has efficiency advantages over Python's `list`. Fully
# understanding this is beyond the scope of our class, though.)

# To turn things into Python types that we currently know and
# understand, here is a `make_list()` function, which converts a numpy
# array into a list of floats. (For simplicity and in preparation for
# lecture, we'll focus on using float values.) Run the following
# `print()` statements to confirm we get identical results for the
# `np.arange()` and `np.linspace()` calls.


def make_list(np_array):
    result = list(np_array)
    for i in range(len(result)):
        result[i] = float(result[i])
    return result


# print()
# print(list(range(10, 20)))
# print(make_list(np.arange(10, 20)))
# print(make_list(np.arange(10, 20, step=1.5)))
# print(make_list(np.linspace(10, 19, num=10)))
# print(make_list(np.linspace(10, 19, num=7)))


# Now let's change the step size to 9/5 = 1.8. This creates five steps
# on the way from 10 to 19, so we expect the outputs to be:
#   [10.0, 11.8, 13.6, 15.4, 17.2, 19.0]


# print()
# print(make_list(np.arange(10, 20, step=1.8)))
# print(make_list(np.linspace(10, 19, num=6)))


# When you run the above code, you should see the expected output from
# `np.linspace()`, but the values from `np.arange()` have trailing zeros
# like so:
#   [10.0, 11.8, 13.600000000000001, 15.400000000000002, ...]

# To find out why this might be the case, we'll try to implement our own
# versions of `arange()` and `linspace()`.


############################################################
# implementing arange() and linspace() ourselves
############################################################


# Below is our custom implementation of `arange()` and `linspace()`.
# We've used the same default values for `step` and `num` as `numpy`
# specifies. Our `custom_arange()` works by continually adding `step`
# and collecting values before they reach or exceed `stop`.

# With this functionality, we can compute `custom_linspace()` by framing
# it as a call to `custom_arange()`. First, we add `step` to `stop` to
# ensure that `stop` as passed to `custom_linspace()` will be included.
# However, we need to compute what the appropriate step is. Complete
# that line and run the following `print()` statements. In both cases,
# the output you get should be identical to the previous output of
# `np.arange()`.


def custom_arange(start, stop, step=1.0):
    result = []
    current = start
    while current < stop:
        result.append(current)
        current += step
    return result


def custom_linspace(start, stop, num=50):
    step = ...  # TODO
    return custom_arange(start, stop + step, step)


# print()
# print(custom_arange(10, 20, step=1.8))
# print(custom_linspace(10, 19, num=6))


# Now let's try an alternate strategy for `custom_linspace()`. Rather
# than accumulating `step` into a `current` variable, let's compute
# incrementing factors of `step` and add **those** values to `start`
# every time. Thus, we can't reuse `custom_arange()` any more, and we'll
# have to build our `results` list ourselves.

# Copy your calculation of `step` from above into the redefined function
# below. When you run the following `print()` statements, you should see
# that our `custom_linspace()` now matches the output of `np.linspace()`.


def custom_linspace(start, stop, num=50):
    step = ...  # TODO
    result = []
    for count in range(num):
        result.append(start + count * step)
    return result


# print()
# print(custom_arange(10, 20, step=1.8))
# print(custom_linspace(10, 19, num=6))


# Have you ever done calculations by hand, where you perform "premature"
# rounding, and then get final results that are off by a couple decimal
# places? What's happening in `arange()` is the computer's version of
# this. As part of lecture tomorrow, we'll discuss how this can happen
# and strategies to avoid surprises.
