diff --git a/.travis.yml b/.travis.yml index 7d95f9b..63fc083 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,18 +15,20 @@ install: - conda update -q conda - conda info -a - | - conda create -q -n test-environment python=$TRAVIS_PYTHON_VERSION pip numpy pandas pytest matplotlib scipy statsmodels + conda create -q -n test-environment python=$TRAVIS_PYTHON_VERSION pip numpy pandas pytest matplotlib scipy statsmodels pytables - source activate test-environment - conda list -- pip install https://github.com/UDST/orca/archive/master.zip -- pip install https://github.com/pksohn/urbansim/archive/python3.zip -- if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then pip install pandana; else - pip install https://github.com/UDST/osmnet/archive/python3-support.zip && - pip install https://github.com/UDST/pandana/archive/python3-support.zip; fi +- pip install pycodestyle +- pip install orca +- pip install https://github.com/udst/urbansim/archive/master.zip +- pip install osmnet pandana - pip install . - cd .. && git clone git@github.com:urbansim/urbansim_parcels.git - pip install ./urbansim_parcels +- cd "$TRAVIS_BUILD_DIR" script: -- cd "$TRAVIS_BUILD_DIR" && py.test -- cd ../urbansim_parcels/sf_example && python simulate.py \ No newline at end of file +- pycodestyle developer +- py.test +- cd ../urbansim_parcels/sf_example && python simulate.py +- cd ../sd_example && python Simulation.py \ No newline at end of file diff --git a/developer/__init__.py b/developer/__init__.py index 7fd229a..0404d81 100644 --- a/developer/__init__.py +++ b/developer/__init__.py @@ -1 +1 @@ -__version__ = '0.2.0' +__version__ = '0.3.0' diff --git a/developer/develop.py b/developer/develop.py index d68c88c..9db4d4d 100644 --- a/developer/develop.py +++ b/developer/develop.py @@ -153,7 +153,7 @@ def to_yaml(self, str_or_buffer=None): logger.debug('serializing Developer model to YAML') return utils.convert_to_yaml(self.to_dict, str_or_buffer) - def pick(self, profit_to_prob_func=None): + def pick(self, profit_to_prob_func=None, custom_selection_func=None): """ Choose the buildings from the list that are feasible to build in order to match the specified demand. @@ -166,6 +166,11 @@ def pick(self, profit_to_prob_func=None): a function which takes the feasibility dataframe and returns a series of probabilities. If no function is passed, the behavior of this method will not change + custom_selection_func: func + User passed function that decides how to select buildings for + development after probabilities are calculated. Must have + parameters (self, df, p) and return a numpy array of buildings to + build (i.e. df.index.values) Returns ------- @@ -174,9 +179,11 @@ def pick(self, profit_to_prob_func=None): DataFrame of buildings to add. These buildings are rows from the DataFrame that is returned from feasibility. """ + df = self.feasibility + empty_warn = "WARNING THERE ARE NO FEASIBLE BUILDINGS TO CHOOSE FROM" - if len(self.feasibility) == 0: - # no feasible buildings, might as well bail + if len(df) == 0 or df.empty: + print(empty_warn) return # Get DataFrame of potential buildings from SqFtProForma steps @@ -184,8 +191,8 @@ def pick(self, profit_to_prob_func=None): df = self._remove_infeasible_buildings(df) df = self._calculate_net_units(df) - if len(df) == 0: - print("WARNING THERE ARE NO FEASIBLE BUILDING TO CHOOSE FROM") + if len(df) == 0 or df.empty: + print(empty_warn) return print("Sum of net units that are profitable: {:,}".format( @@ -193,7 +200,7 @@ def pick(self, profit_to_prob_func=None): # Generate development probabilities and pick buildings to build p, df = self._calculate_probabilities(df, profit_to_prob_func) - build_idx = self._buildings_to_build(df, p) + build_idx = self._select_buildings(df, p, custom_selection_func) # Drop built buildings from self.feasibility attribute if desired self._drop_built_buildings(build_idx) @@ -295,6 +302,8 @@ def _remove_infeasible_buildings(self, df): ------- df : DataFrame """ + if len(df) == 0 or df.empty: + return df df = df[df.max_profit_far > 0] self.ave_unit_size[ @@ -326,6 +335,8 @@ def _calculate_net_units(self, df): ------- df : DataFrame """ + if len(df) == 0 or df.empty: + return df if self.residential: df['net_units'] = df.residential_units - df.current_units @@ -362,7 +373,7 @@ def _calculate_probabilities(df, profit_to_prob_func): p = df.max_profit_per_size.values / df.max_profit_per_size.sum() return p, df - def _buildings_to_build(self, df, p): + def _select_buildings(self, df, p, custom_selection_func): """ Helper method to pick(). Selects buildings to build based on development probabilities. @@ -373,6 +384,11 @@ def _buildings_to_build(self, df, p): DataFrame of buildings from _calculate_probabilities method p : Series Probabilities from _calculate_probabilities method + custom_selection_func: func + User passed function that decides how to select buildings for + development after probabilities are calculated. Must have + parameters (self, df, p) and return a numpy array of buildings to + build (i.e. df.index.values) Returns ------- @@ -381,7 +397,9 @@ def _buildings_to_build(self, df, p): """ - if df.net_units.sum() < self.target_units: + if custom_selection_func is not None: + build_idx = custom_selection_func(self, df, p) + elif df.net_units.sum() < self.target_units: print("WARNING THERE WERE NOT ENOUGH PROFITABLE UNITS TO", "MATCH DEMAND") build_idx = df.index.values @@ -442,7 +460,11 @@ def _prepare_new_buildings(self, df, build_idx): """ new_df = df.loc[build_idx] - new_df.index.name = "parcel_id" + + drop = True + if 'parcel_id' not in df.columns: + new_df.index.name = "parcel_id" + drop = False if self.year is not None: new_df["year_built"] = self.year @@ -453,4 +475,4 @@ def _prepare_new_buildings(self, df, build_idx): new_df["stories"] = new_df.stories.apply(np.ceil) - return new_df.reset_index() + return new_df.reset_index(drop=drop) diff --git a/developer/sqftproforma.py b/developer/sqftproforma.py index 469b1b4..46d9ec7 100644 --- a/developer/sqftproforma.py +++ b/developer/sqftproforma.py @@ -1,4 +1,5 @@ from __future__ import print_function, division, absolute_import +import inspect import numpy as np import pandas as pd import logging @@ -151,6 +152,8 @@ def __init__(self, parcel_sizes, fars, uses, residential_uses, forms, cap_rate, parking_rates, sqft_per_rate, parking_configs, costs, heights_for_costs, parking_sqft_d, parking_cost_d, height_per_story, max_retail_height, max_industrial_height, + construction_months, construction_sqft_for_months, + loan_to_cost_ratio, drawdown_factor, interest_rate, loan_fees, residential_to_yearly=True, forms_to_test=None, only_built=True, pass_through=None, simple_zoning=False, parcel_filter=None @@ -175,6 +178,12 @@ def __init__(self, parcel_sizes, fars, uses, residential_uses, forms, self.height_per_story = height_per_story self.max_retail_height = max_retail_height self.max_industrial_height = max_industrial_height + self.construction_months = construction_months + self.construction_sqft_for_months = construction_sqft_for_months + self.loan_to_cost_ratio = loan_to_cost_ratio + self.drawdown_factor = drawdown_factor + self.interest_rate = interest_rate + self.loan_fees = loan_fees self.residential_to_yearly = residential_to_yearly self.forms_to_test = forms_to_test or sorted(self.forms.keys()) @@ -244,6 +253,9 @@ def _convert_types(self): self.residential_uses].sum() self.costs = np.transpose( np.array([self.costs[use] for use in self.uses])) + self.construction_months = np.transpose( + np.array([self.construction_months[use] for use in self.uses]) + ) @classmethod def from_yaml(cls, yaml_str=None, str_or_buffer=None): @@ -274,6 +286,12 @@ def from_yaml(cls, yaml_str=None, str_or_buffer=None): cfg['parking_sqft_d'], cfg['parking_cost_d'], cfg['height_per_story'], cfg['max_retail_height'], cfg['max_industrial_height'], + cfg['construction_months'], + cfg['construction_sqft_for_months'], + cfg['loan_to_cost_ratio'], + cfg['drawdown_factor'], + cfg['interest_rate'], + cfg['loan_fees'], cfg.get('residential_to_yearly', True), cfg.get('forms_to_test', None), cfg.get('only_built', True), @@ -330,7 +348,17 @@ def get_defaults(): 'mixedresidential', 'office', 'residential', 'retail'], 'pass_through': [], - 'simple_zoning': False + 'simple_zoning': False, + 'construction_months': { + 'industrial': [12.0, 14.0, 18.0, 24.0], + 'office': [12.0, 14.0, 18.0, 24.0], + 'residential': [12.0, 14.0, 18.0, 24.0], + 'retail': [12.0, 14.0, 18.0, 24.0]}, + 'construction_sqft_for_months': [10000, 20000, 50000, np.inf], + 'loan_to_cost_ratio': .7, + 'drawdown_factor': .6, + 'interest_rate': .05, + 'loan_fees': .02 } @classmethod @@ -363,7 +391,9 @@ def to_dict(self): 'parking_sqft_d', 'parking_cost_d', 'height_per_story', 'max_retail_height', 'max_industrial_height', 'residential_to_yearly', 'parcel_filter', 'only_built', - 'forms_to_test', 'pass_through', 'simple_zoning'] + 'forms_to_test', 'pass_through', 'simple_zoning', + 'construction_sqft_for_months', 'loan_to_cost_ratio', + 'drawdown_factor', 'interest_rate', 'loan_fees'] results = {} for attribute in unconverted: @@ -396,6 +426,13 @@ def to_dict(self): costs[use] = values.tolist() results['costs'] = costs + time = {} + time_transposed = self.construction_months.transpose() + for index, use in enumerate(self.uses): + values = time_transposed[index] + time[use] = values.tolist() + results['construction_months'] = time + return results def to_yaml(self, str_or_buffer=None): @@ -419,7 +456,8 @@ def to_yaml(self, str_or_buffer=None): logger.debug('serializing SqftProForma model to YAML') return utils.convert_to_yaml(self.to_dict, str_or_buffer) - def lookup(self, form, df): + def lookup(self, form, df, modify_df=None, modify_revenues=None, + modify_costs=None, modify_profits=None, **kwargs): """ This function does the developer model lookups for all the actual input data. @@ -428,9 +466,21 @@ def lookup(self, form, df): ---------- form : string One of the forms specified in the configuration file - df: dataframe + df : DataFrame Pass in a single data frame which is indexed by parcel_id and has the following columns + modify_df : function + Function to modify lookup DataFrame before profit calculations. + Must have (self, form, df) as parameters. + modify_revenues : function + Function to modify revenue ndarray during profit calculations. + Must have (self, form, df, revenues) as parameters. + modify_costs : function + Function to modify cost ndarray during profit calculations. + Must have (self, form, df, costs) as parameters. + modify_profits : function + Function to modify profit ndarray during profit calculations. + Must have (self, form, df, profits) as parameters. Input Dataframe Columns rent : dataframe @@ -446,7 +496,7 @@ def lookup(self, form, df): A series representing the maximum far allowed by zoning. Buildings will not be built above these fars. max_height : series - A series representing the maxmium height allowed by zoning. + A series representing the maximum height allowed by zoning. Buildings will not be built above these heights. Will pick between the min of the far and height, will ignore on of them if one is nan, but will not build if both are nan. @@ -487,8 +537,288 @@ def lookup(self, form, df): max_far and max_height from the input dataframe). """ - lookup_object = SqFtProFormaLookup(**self.__dict__) - return lookup_object.lookup(form, df) + if self.simple_zoning: + df = self._simple_zoning(form, df) + + lookup = pd.concat( + self._lookup_parking_cfg(form, parking_config, df, modify_df, + modify_revenues, modify_costs, + modify_profits) + for parking_config in self.parking_configs) + + if len(lookup) == 0: + return pd.DataFrame() + + result = self._max_profit_parking(lookup) + + if self.residential_to_yearly and "residential" in self.pass_through: + result["residential"] /= self.cap_rate + + return result + + @staticmethod + def _simple_zoning(form, df): + """ + Replaces max_height and either max_far or max_dua with NaNs + + Parameters + ---------- + form : str + Name of form passed to lookup method + df : DataFrame + DataFrame passed to lookup method + + Returns + ------- + df : DataFrame + """ + + if form == "residential": + # these are new computed in the effective max_dua method + df["max_far"] = pd.Series() + df["max_height"] = pd.Series() + else: + # these are new computed in the effective max_far method + df["max_dua"] = pd.Series() + df["max_height"] = pd.Series() + + return df + + @staticmethod + def _max_profit_parking(df): + """ + Return parcels DataFrame with parking configuration that maximizes + profit + + Parameters + ---------- + df: DataFrame + DataFrame passed to lookup method + + Returns + ------- + result : DataFrame + """ + + max_profit_ind = df.pivot( + columns="parking_config", + values="max_profit").idxmax(axis=1).to_frame("parking_config") + + df.set_index(["parking_config"], append=True, inplace=True) + max_profit_ind.set_index(["parking_config"], append=True, + inplace=True) + + # get the max_profit idx + result = df.loc[max_profit_ind.index].reset_index(1) + + return result + + def _lookup_parking_cfg(self, form, parking_config, df, + modify_df, modify_revenues, modify_costs, + modify_profits): + """ + This is the core square foot pro forma calculation. For each form and + parking configuration, generate DataFrame with profitability + information + + Parameters + ---------- + form : str + Name of form + parking_config : str + Name of parking configuration + df : DataFrame + DataFrame of developable sites/parcels passed to lookup() method + modify_df : func + Function to modify lookup DataFrame before profit calculations. + Must have (self, form, df) as parameters. + modify_revenues : func + Function to modify revenue ndarray during profit calculations. + Must have (self, form, df, revenues) as parameters. + modify_costs : func + Function to modify cost ndarray during profit calculations. + Must have (self, form, df, costs) as parameters. + modify_profits : func + Function to modify profit ndarray during profit calculations. + Must have (self, form, df, profits) as parameters. + + Returns + ------- + outdf : DataFrame + """ + # don't really mean to edit the df that's passed in + df = df.copy() + + # Reference table for this form and parking configuration + dev_info = self.reference_dict[(form, parking_config)] + + # Helper values + cost_sqft_col = columnize(dev_info.ave_cost_sqft.values) + cost_sqft_index_col = columnize(dev_info.index.values) + parking_sqft_ratio = columnize(dev_info.parking_sqft_ratio.values) + heights = columnize(dev_info.height.values) + months = columnize(dev_info.construction_months.values) + resratio = self.res_ratios[form] + nonresratio = 1.0 - resratio + df['weighted_rent'] = np.dot(df[self.uses], self.forms[form]) + + # Allow for user modification of DataFrame here + df = modify_df(self, form, df) if modify_df else df + + # ZONING FILTERS + # Minimize between max_fars and max_heights + df['max_far_from_heights'] = (df.max_height + / self.height_per_story + * self.parcel_coverage) + + df['min_max_fars'] = self._min_max_fars(df, resratio) + + if self.only_built: + df = df.query('min_max_fars > 0 and parcel_size > 0') + + # turn fars and heights into nans which are not allowed by zoning + # (so we can fillna with one of the other zoning constraints) + fars = np.repeat(cost_sqft_index_col, len(df.index), axis=1) + fars[fars > df.min_max_fars.values + .01] = np.nan + heights = np.repeat(heights, len(df.index), axis=1) + fars[heights > df.max_height.values + .01] = np.nan + + # PROFIT CALCULATION + # parcel sizes * possible fars + building_bulks = fars * df.parcel_size.values + + # cost to build the new building + building_costs = building_bulks * cost_sqft_col + + # add cost to buy the current building + total_construction_costs = building_costs + df.land_cost.values + + # Financing costs + loan_amount = total_construction_costs * self.loan_to_cost_ratio + months = np.repeat(months, len(df.index), axis=1) + interest = (loan_amount + * self.drawdown_factor + * (self.interest_rate / 12 * months)) + points = loan_amount * self.loan_fees + total_financing_costs = interest + points + total_development_costs = (total_construction_costs + + total_financing_costs) + + # rent to make for the new building + building_revenue = (building_bulks + * (1 - parking_sqft_ratio) + * self.building_efficiency + * df.weighted_rent.values + / self.cap_rate) + + # profit for each form, including user modification of + # revenues, costs, and/or profits + + building_revenue = (modify_revenues(self, form, df, building_revenue) + if modify_revenues else building_revenue) + + total_development_costs = ( + modify_costs(self, form, df, total_development_costs) + if modify_costs else total_development_costs) + + profit = building_revenue - total_development_costs + + profit = (modify_profits(self, form, df, profit) + if modify_profits else profit) + + profit = profit.astype('float') + profit[np.isnan(profit)] = -np.inf + maxprofitind = np.argmax(profit, axis=0) + + def twod_get(indexes, arr): + return arr[indexes, np.arange(indexes.size)].astype('float') + + outdf = pd.DataFrame({ + 'building_sqft': twod_get(maxprofitind, building_bulks), + 'building_cost': twod_get(maxprofitind, building_costs), + 'parking_ratio': parking_sqft_ratio[maxprofitind].flatten(), + 'stories': twod_get(maxprofitind, + heights) / self.height_per_story, + 'total_cost': twod_get(maxprofitind, total_development_costs), + 'building_revenue': twod_get(maxprofitind, building_revenue), + 'max_profit_far': twod_get(maxprofitind, fars), + 'max_profit': twod_get(maxprofitind, profit), + 'parking_config': parking_config, + 'construction_time': twod_get(maxprofitind, months), + 'financing_cost': twod_get(maxprofitind, total_financing_costs) + }, index=df.index) + + if self.pass_through: + outdf[self.pass_through] = df[self.pass_through] + + outdf["residential_sqft"] = (outdf.building_sqft * + self.building_efficiency * + resratio) + outdf["non_residential_sqft"] = (outdf.building_sqft * + self.building_efficiency * + nonresratio) + + if self.only_built: + outdf = outdf.query('max_profit > 0').copy() + else: + outdf = outdf.loc[outdf.max_profit != -np.inf].copy() + + return outdf + + def _min_max_fars(self, df, resratio): + """ + In case max_dua is passed in the DataFrame, + now also minimize with max_dua from zoning - since this pro forma is + really geared toward per sqft metrics, this is a bit tricky. dua + is converted to floorspace and everything just works (floor space + will get covered back to units in developer.pick() but we need to + test the profitability of the floorspace allowed by max_dua here. + + Parameters + ---------- + df : DataFrame + DataFrame of developable sites/parcels passed to lookup() method + resratio : numeric + Residential ratio for this form + + Returns + ------- + Series + """ + + if 'max_dua' in df.columns and resratio > 0: + # if max_dua is in the data frame, ave_unit_size must also be there + assert 'ave_unit_size' in df.columns + + df['max_far_from_dua'] = ( + # this is the max_dua times the parcel size in acres, which + # gives the number of units that are allowable on the parcel + df.max_dua * (df.parcel_size / 43560) * + + # times by the average unit size which gives the square footage + # of those units + df.ave_unit_size / + + # divided by the building efficiency which is a + # factor that indicates that the actual units are not the whole + # FAR of the building + self.building_efficiency / + + # divided by the resratio which is a factor that indicates + # that the actual units are not the only use of the building + resratio / + + # divided by the parcel size again in order to get FAR. + # I recognize that parcel_size actually + # cancels here as it should, but the calc was hard to get right + # and it's just so much more transparent to have it in there + # twice + df.parcel_size) + return df[['max_far_from_heights', + 'max_far', 'max_far_from_dua']].min(axis=1) + else: + return df[ + ['max_far_from_heights', 'max_far']].min(axis=1) def get_debug_info(self, form, parking_config): """ @@ -594,7 +924,8 @@ def __init__(self, parcel_sizes, fars, forms, profit_factor, parcel_coverage, parking_rates, sqft_per_rate, parking_configs, costs, heights_for_costs, parking_sqft_d, parking_cost_d, height_per_story, max_retail_height, - max_industrial_height, **kwargs): + max_industrial_height, construction_sqft_for_months, + construction_months, **kwargs): self.fars = fars self.parcel_sizes = parcel_sizes @@ -611,6 +942,8 @@ def __init__(self, parcel_sizes, fars, forms, self.height_per_story = height_per_story self.max_retail_height = max_retail_height self.max_industrial_height = max_industrial_height + self.construction_sqft_for_months = construction_sqft_for_months + self.construction_months = construction_months self.tiled_parcel_sizes = columnize( np.repeat(self.parcel_sizes, self.fars.size)) @@ -644,43 +977,71 @@ def _generate_reference(self): self.reference_dict = df_d def _reference_dataframe(self, name, uses_distrib, parking_config): + """ + This generates a reference DataFrame for each form and parking + configuration, which provides development information for various + floor-to-area ratios. - # going to make a dataframe to store values to make - # pro forma results transparent - df = pd.DataFrame(index=self.fars) + Parameters + ---------- + name : str + Name of form + uses_distrib : ndarray + The distribution of uses in this form + parking_config : str + Name of parking configuration - df['far'] = self.fars - df['pclsz'] = self.tiled_parcel_sizes + Returns + ------- + df : DataFrame + """ + + df = pd.DataFrame(index=self.fars) + # Array of square footage values for each FAR building_bulk = self._building_bulk(uses_distrib, parking_config) - df['building_sqft'] = building_bulk - parkingstalls = (building_bulk * - np.sum(uses_distrib * self.parking_rates) / - self.sqft_per_rate) - df['spaces'] = parkingstalls + # Array of parking stalls required for each FAR + parking_stalls = (building_bulk + * np.sum(uses_distrib * self.parking_rates) + / self.sqft_per_rate) - df['park_sqft'] = self._park_sqft(parking_config, parkingstalls) - stories = self._stories(parking_config, building_bulk, parkingstalls) + # Array of stories built at each FAR + stories = self._stories(parking_config, building_bulk, parking_stalls) - df['total_built_sqft'] = df.building_sqft + df.park_sqft - df['parking_sqft_ratio'] = df.park_sqft / df.total_built_sqft + # Square feet of parking required for this configuration (constant) + park_sqft = self._park_sqft(parking_config, parking_stalls) - stories /= self.parcel_coverage - df['stories'] = np.ceil(stories) + # Array of total parking cost required for each FAR + park_cost = (self.parking_cost_d[parking_config] + * parking_stalls + * self.parking_sqft_d[parking_config]) - df['height'] = df.stories * self.height_per_story - df['build_cost_sqft'] = self._building_cost(uses_distrib, stories) - df['build_cost'] = df.build_cost_sqft * df.building_sqft + # Array of building cost per square foot for each FAR + building_cost_per_sqft = self._building_cost(uses_distrib, stories) + + total_built_sqft = building_bulk + park_sqft - df['park_cost'] = (self.parking_cost_d[parking_config] * - parkingstalls * - self.parking_sqft_d[parking_config]) + # Array of construction time for each FAR + construction_months = self._construction_time(uses_distrib, + total_built_sqft) + df['far'] = self.fars + df['pclsz'] = self.tiled_parcel_sizes + df['building_sqft'] = building_bulk + df['spaces'] = parking_stalls + df['park_sqft'] = park_sqft + df['total_built_sqft'] = total_built_sqft + df['parking_sqft_ratio'] = df.park_sqft / df.total_built_sqft + df['stories'] = np.ceil(stories) + df['height'] = df.stories * self.height_per_story + df['build_cost_sqft'] = building_cost_per_sqft + df['build_cost'] = df.build_cost_sqft * df.building_sqft + df['park_cost'] = park_cost df['cost'] = df.build_cost + df.park_cost - df['ave_cost_sqft'] = ( - (df.cost / df.total_built_sqft) * - self.profit_factor) + df['ave_cost_sqft'] = ((df.cost / df.total_built_sqft) + * self.profit_factor) + df['construction_months'] = construction_months if name == 'retail': df['ave_cost_sqft'][ @@ -752,332 +1113,91 @@ def _building_bulk(self, uses_distrib, parking_config): return building_bulk - def _park_sqft(self, parking_config, parkingstalls): - - if parking_config in ['underground', 'deck']: - return parkingstalls * self.parking_sqft_d[parking_config] - if parking_config == 'surface': - return 0 - - def _stories(self, parking_config, building_bulk, parkingstalls): - - if parking_config == 'underground': - stories = building_bulk / self.tiled_parcel_sizes - if parking_config == 'deck': - stories = ((building_bulk + parkingstalls * - self.parking_sqft_d[parking_config]) / - self.tiled_parcel_sizes) - if parking_config == 'surface': - stories = (building_bulk / - (self.tiled_parcel_sizes - parkingstalls * - self.parking_sqft_d[parking_config])) - # not all fars support surface parking - stories[stories < 0.0] = np.nan - # I think we can assume that stories over 3 - # do not work with surface parking - stories[stories > 5.0] = np.nan - - return stories - - -class SqFtProFormaLookup(object): - - def __init__(self, reference_dict, res_ratios, uses, forms, - building_efficiency, parcel_coverage, cap_rate, - parking_configs, height_per_story, residential_to_yearly, - only_built, pass_through, simple_zoning, **kwargs): - - self.reference_dict = reference_dict - self.res_ratios = res_ratios - self.uses = uses - self.forms = forms - self.building_efficiency = building_efficiency - self.parcel_coverage = parcel_coverage - self.cap_rate = cap_rate - self.parking_configs = parking_configs - self.height_per_story = height_per_story - self.residential_to_yearly = residential_to_yearly - self.only_built = only_built - self.pass_through = pass_through - self.simple_zoning = simple_zoning - - def lookup(self, form, df): + def _park_sqft(self, parking_config, parking_stalls): """ - This function does the developer model lookups for all the actual input - data. + Generate building square footage required for a parking configuration Parameters ---------- - form : string - One of the forms specified in the configuration file - df: dataframe - Pass in a single data frame which is indexed by parcel_id and has - the following columns - - Input Dataframe Columns - rent : dataframe - A set of columns, one for each of the uses passed in the - configuration. Values are yearly rents for that use. Typical column - names would be "residential", "retail", "industrial" and "office" - land_cost : series - A series representing the CURRENT yearly rent for each parcel. - Used to compute acquisition costs for the parcel. - parcel_size : series - A series representing the parcel size for each parcel. - max_far : series - A series representing the maximum far allowed by zoning. Buildings - will not be built above these fars. - max_height : series - A series representing the maxmium height allowed by zoning. - Buildings will not be built above these heights. Will pick between - the min of the far and height, will ignore on of them if one is - nan, but will not build if both are nan. - max_dua : series, optional - A series representing the maximum dwelling units per acre allowed - by zoning. If max_dua is passed, the average unit size should be - passed below to translate from dua to floor space. - ave_unit_size : series, optional - This is required if max_dua is passed above, otherwise it is - optional. This is the same as the parameter to Developer.pick() - (it should be the same series). + parking_config : str + Name of parking configuration + parking_stalls : numeric + Number of parking stalls required Returns ------- - index : Series, int - parcel identifiers - building_sqft : Series, float - The number of square feet for the building to build. Keep in mind - this includes parking and common space. Will need a helpful - function to convert from gross square feet to actual usable square - feet in residential units. - building_cost : Series, float - The cost of constructing the building as given by the - ave_cost_per_sqft from the cost model (for this FAR) and the number - of square feet. - total_cost : Series, float - The cost of constructing the building plus the cost of acquisition - of the current parcel/building. - building_revenue : Series, float - The NPV of the revenue for the building to be built, which is the - number of square feet times the yearly rent divided by the cap - rate (with a few adjustment factors including building efficiency). - max_profit_far : Series, float - The FAR of the maximum profit building (constrained by the max_far - and max_height from the input dataframe). - max_profit : - The profit for the maximum profit building (constrained by the - max_far and max_height from the input dataframe). + park_sqft : numeric """ - if self.simple_zoning: - df = self._simple_zoning(form, df) - - lookup = pd.concat( - self._lookup_parking_cfg(form, parking_config, df) - for parking_config in self.parking_configs) - - if len(lookup) == 0: - return pd.DataFrame() - - result = self._max_profit_parking(lookup) - - if self.residential_to_yearly and "residential" in self.pass_through: - result["residential"] /= self.cap_rate - - return result + if parking_config in ['underground', 'deck']: + return parking_stalls * self.parking_sqft_d[parking_config] + if parking_config == 'surface': + return 0 - @staticmethod - def _simple_zoning(form, df): + def _stories(self, parking_config, building_bulk, parking_stalls): """ - Replaces max_height and either max_far or max_dua with NaNs + Calculates number of stories built at various FARs, given + building bulk, number of parking stalls, and parking configuration Parameters ---------- - form : str - Name of form passed to lookup method - df : DataFrame - DataFrame passed to lookup method + parking_config : str + Name of parking configuration + building_bulk : ndarray + Array of total square footage values for each FAR + parking_stalls : ndarray + Number of parking stalls required for each FAR Returns ------- - df : DataFrame + stories : ndarray + """ - if form == "residential": - # these are new computed in the effective max_dua method - df["max_far"] = pd.Series() - df["max_height"] = pd.Series() - else: - # these are new computed in the effective max_far method - df["max_dua"] = pd.Series() - df["max_height"] = pd.Series() + if parking_config == 'underground': + stories = building_bulk / self.tiled_parcel_sizes + if parking_config == 'deck': + stories = ((building_bulk + + parking_stalls + * self.parking_sqft_d[parking_config]) + / self.tiled_parcel_sizes) + if parking_config == 'surface': + stories = (building_bulk + / (self.tiled_parcel_sizes + - parking_stalls + * self.parking_sqft_d[parking_config])) + # not all fars support surface parking + stories[stories < 0.0] = np.nan + # I think we can assume that stories over 3 + # do not work with surface parking + stories[stories > 5.0] = np.nan - return df + stories /= self.parcel_coverage - @staticmethod - def _max_profit_parking(df): + return stories + + def _construction_time(self, use_mix, building_bulks): """ - Return parcels DataFrame with parking configuration that maximizes - profit + Calculate construction time in months for each development site. Parameters ---------- - df: DataFrame - DataFrame passed to lookup method + use_mix : array + The mix of uses for this form + building_bulks : array + Array of square footage for each potential building Returns ------- - result : DataFrame + construction_times : array """ - max_profit_ind = df.pivot( - columns="parking_config", - values="max_profit").idxmax(axis=1).to_frame("parking_config") - - df.set_index(["parking_config"], append=True, inplace=True) - max_profit_ind.set_index(["parking_config"], append=True, - inplace=True) - - # get the max_profit idx - result = df.loc[max_profit_ind.index].reset_index(1) - - return result - - def _lookup_parking_cfg(self, form, parking_config, df): - - dev_info = self.reference_dict[(form, parking_config)] - - cost_sqft_col = columnize(dev_info.ave_cost_sqft.values) - cost_sqft_index_col = columnize(dev_info.index.values) - parking_sqft_ratio = columnize(dev_info.parking_sqft_ratio.values) - heights = columnize(dev_info.height.values) - - # don't really mean to edit the df that's passed in - df = df.copy() - - # weighted rent for this form - df['weighted_rent'] = np.dot(df[self.uses], self.forms[form]) - - # min between max_fars and max_heights - df['max_far_from_heights'] = (df.max_height / - self.height_per_story * - self.parcel_coverage) - - resratio = self.res_ratios[form] - nonresratio = 1.0 - resratio - - # now also minimize with max_dua from zoning - since this pro forma is - # really geared toward per sqft metrics, this is a bit tricky. dua - # is converted to floorspace and everything just works (floor space - # will get covered back to units in developer.pick() but we need to - # test the profitability of the floorspace allowed by max_dua here. - - df['min_max_fars'] = self._min_max_fars(df, resratio) - - if self.only_built: - df = df.query('min_max_fars > 0 and parcel_size > 0') - - fars = np.repeat(cost_sqft_index_col, len(df.index), axis=1) - - # turn fars into nans which are not allowed by zoning - # (so we can fillna with one of the other zoning constraints) - fars[fars > df.min_max_fars.values + .01] = np.nan - - # same thing for heights - heights = np.repeat(heights, len(df.index), axis=1) - - # turn heights into nans which are not allowed by zoning - # (so we can fillna with one of the other zoning constraints) - fars[heights > df.max_height.values + .01] = np.nan - - # parcel sizes * possible fars - building_bulks = fars * df.parcel_size.values - - # cost to build the new building - building_costs = building_bulks * cost_sqft_col - - # add cost to buy the current building - total_costs = building_costs + df.land_cost.values - - # rent to make for the new building - building_revenue = (building_bulks * - (1 - parking_sqft_ratio) * - self.building_efficiency * - df.weighted_rent.values / - self.cap_rate) - - # profit for each form - profit = building_revenue - total_costs - - profit = profit.astype('float') - profit[np.isnan(profit)] = -np.inf - maxprofitind = np.argmax(profit, axis=0) - - def twod_get(indexes, arr): - return arr[indexes, np.arange(indexes.size)].astype('float') - - outdf = pd.DataFrame({ - 'building_sqft': twod_get(maxprofitind, building_bulks), - 'building_cost': twod_get(maxprofitind, building_costs), - 'parking_ratio': parking_sqft_ratio[maxprofitind].flatten(), - 'stories': twod_get(maxprofitind, - heights) / self.height_per_story, - 'total_cost': twod_get(maxprofitind, total_costs), - 'building_revenue': twod_get(maxprofitind, building_revenue), - 'max_profit_far': twod_get(maxprofitind, fars), - 'max_profit': twod_get(maxprofitind, profit), - 'parking_config': parking_config - }, index=df.index) - - if self.pass_through: - outdf[self.pass_through] = df[self.pass_through] - - outdf["residential_sqft"] = (outdf.building_sqft * - self.building_efficiency * - resratio) - outdf["non_residential_sqft"] = (outdf.building_sqft * - self.building_efficiency * - nonresratio) - - if self.only_built: - outdf = outdf.query('max_profit > 0').copy() - else: - outdf = outdf.loc[outdf.max_profit != -np.inf].copy() - - return outdf - - def _min_max_fars(self, df, resratio): - - if 'max_dua' in df.columns and resratio > 0: - # if max_dua is in the data frame, ave_unit_size must also be there - assert 'ave_unit_size' in df.columns - - df['max_far_from_dua'] = ( - # this is the max_dua times the parcel size in acres, which - # gives the number of units that are allowable on the parcel - df.max_dua * (df.parcel_size / 43560) * - - # times by the average unit size which gives the square footage - # of those units - df.ave_unit_size / - - # divided by the building efficiency which is a - # factor that indicates that the actual units are not the whole - # FAR of the building - self.building_efficiency / - - # divided by the resratio which is a factor that indicates - # that the actual units are not the only use of the building - resratio / - - # divided by the parcel size again in order to get FAR. - # I recognize that parcel_size actually - # cancels here as it should, but the calc was hard to get right - # and it's just so much more transparent to have it in there - # twice - df.parcel_size) - return df[['max_far_from_heights', - 'max_far', 'max_far_from_dua']].min(axis=1) - else: - return df[ - ['max_far_from_heights', 'max_far']].min(axis=1) + # Look at square footage and return matching index in list of + # construction times + month_indices = np.searchsorted(self.construction_sqft_for_months, + building_bulks) + # Get the construction time for each dev site, for all uses + months_array_all_uses = self.construction_months[month_indices] + # Dot product to get appropriate time for uses being evaluated + construction_times = np.dot(months_array_all_uses, use_mix) + return construction_times diff --git a/developer/tests/test_sqftproforma.py b/developer/tests/test_sqftproforma.py index 6d00758..78affdb 100644 --- a/developer/tests/test_sqftproforma.py +++ b/developer/tests/test_sqftproforma.py @@ -40,7 +40,7 @@ def simple_dev_inputs_high_cost(): @pytest.fixture def simple_dev_inputs_low_cost(): sdi = simple_dev_inputs() - sdi.land_cost /= 20 + sdi.land_cost /= 100 return sdi @@ -161,11 +161,15 @@ def test_reasonable_feasibility_results(): # confirm cost per sqft is between 100 and 400 per sqft assert 100 < first.building_cost/first.building_sqft < 400 # total cost equals building cost plus land cost - assert first.total_cost == first.building_cost + df.iloc[0].land_cost + assert first.total_cost == (first.building_cost + + df.iloc[0].land_cost + + first.financing_cost) # revenue per sqft should be between 200 and 800 per sqft assert 200 < first.building_revenue/first.building_sqft < 800 - assert first.residential_sqft == first.building_sqft * pf.building_efficiency - # because of parcel inefficiency, stories should be greater than far, but not too much more + assert first.residential_sqft == (first.building_sqft + * pf.building_efficiency) + # because of parcel inefficiency, + # stories should be greater than far, but not too much more assert first.max_profit_far < first.stories < first.max_profit_far * 3.0 assert first.non_residential_sqft == 0 assert first.max_profit > 0 diff --git a/setup.py b/setup.py index 472f57e..d03ff58 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='developer', - version='0.2.0', + version='0.3.0', description='Urbansim developer model', author='UrbanSim Inc.', author_email='info@urbansim.com', @@ -28,4 +28,4 @@ extras_require={ 'pandana': ['pandana>=0.1'] } -) \ No newline at end of file +)