aki f02f848ada feat: Implement full problem generation
This commit marks a major milestone in the problem generator project.

Key systems include:
- Fully functional problem generation for all 23 defined concepts (Simple Interest, Compound Interest, Banker's Discount, Effective Rate, Continuous Compounding, Exact/Ordinary Simple Interest).
- Robust date handling for date-specific interest calculations.
- Improved solution presentation with accurate "Substitute Values" step and complete variable descriptions.
- Interactive problem display in `main.py`: problem statement shown first, question and solution revealed on user input.
- Added plausibility checks for rate calculations to avoid unrealistic negative rates.
- Comprehensive `README.md` update:
    - Detailed system architecture (modules, data files, Mermaid diagram).
    - List of all currently covered financial concepts.
    - Instructions for running and extending the generator.
    - Discussion of scope for future enhancements (Equation of Value, Gradients, etc.).
- Refinements to `value_sampler.py` for better formatting and handling of None values.
- Updates to `text_snippets.json` for complete variable descriptions and improved solution step phrasing.
- Updates to `value_ranges.json` for date generation parameters.
- `problem_engine.py` now systematically tests all concepts when run directly.
- Added `uv.lock` to track resolved dependencies.

The system is capable of generating a wide variety of engineering economy problems with detailed, step-by-step solutions and an interactive user experience.
2025-05-09 11:56:16 +08:00

180 lines
8.4 KiB
Python

import random
import decimal
import datetime # Added for type checking
from src.data_loader import get_value_ranges
from src import date_utils # Added for date formatting
# Cache loaded data to avoid repeated file I/O
VALUE_RANGES = None
def _get_value_ranges_cached():
"""Returns cached value_ranges data, loading if not already cached."""
global VALUE_RANGES
if VALUE_RANGES is None:
VALUE_RANGES = get_value_ranges()
return VALUE_RANGES
def get_random_float(min_val, max_val, precision=None):
"""Generates a random float between min_val and max_val with specified precision."""
val = random.uniform(min_val, max_val)
if precision is not None:
# Using Decimal for precise rounding
return float(decimal.Decimal(str(val)).quantize(decimal.Decimal('1e-' + str(precision)), rounding=decimal.ROUND_HALF_UP))
return val
def get_random_int(min_val, max_val):
"""Generates a random integer between min_val and max_val (inclusive)."""
return random.randint(min_val, max_val)
def get_value_for_variable(variable_key):
"""
Generates a random value for a given variable key based on value_ranges.json.
Returns a dictionary containing the value and its unit/currency if applicable.
Example: {'value': 15000.50, 'currency': 'Php', 'display_str': 'Php 15,000.50'}
{'value': 0.12, 'unit_display': '% per annum', 'display_str': '12.00% per annum'}
{'value': 5, 'unit': 'years', 'display_str': '5 years'}
"""
ranges = _get_value_ranges_cached()
if not ranges or variable_key not in ranges:
print(f"Error: No range definition found for variable key '{variable_key}' in value_ranges.json")
return None
config = ranges[variable_key]
val = None
result = {'key': variable_key}
if config.get("integer", False):
val = get_random_int(config["min"], config["max"])
result['value'] = val
else:
# For floats, use internal_precision for generation if available, otherwise default to a reasonable precision
# Display precision will be handled separately or by a formatting function
internal_precision = config.get("internal_precision", config.get("decimals", 4)) # Default to 4 if no precision specified
val = get_random_float(config["min"], config["max"], internal_precision)
result['value'] = val
if "currency" in config:
result["currency"] = config["currency"]
if "unit" in config:
result["unit"] = config["unit"]
if "unit_display" in config: # For rates like "% per annum"
result["unit_display"] = config["unit_display"]
# Store precision details for later formatting
result["display_precision"] = config.get("display_precision", config.get("decimals")) # Decimals is often used for currency
return result
def get_random_compounding_frequency():
"""Selects a random compounding frequency and its 'm' value."""
ranges = _get_value_ranges_cached()
if not ranges or "compounding_frequency_options" not in ranges:
print("Error: 'compounding_frequency_options' not found in value_ranges.json")
return None, None
options = ranges["compounding_frequency_options"]
frequency_name = random.choice(list(options.keys()))
m_value = options[frequency_name]
return {"name": frequency_name, "m_value": m_value, "key": "m_compounding_periods_per_year", "value": m_value}
def format_value_for_display(value_data):
"""
Formats a value generated by get_value_for_variable for display.
Example input: {'key': 'principal', 'value': 15000.5, 'currency': 'Php', 'display_precision': 2}
Example output: "Php 15,000.50"
Example input: {'key': 'simple_interest_rate_annual', 'value': 0.12, 'unit_display': '% per annum', 'display_precision': 2}
Example output: "12.00% per annum"
"""
# --- BEGIN DEBUG PRINT ---
# print(f"DEBUG: format_value_for_display received value_data: {value_data}")
# --- END DEBUG PRINT ---
if not isinstance(value_data, dict) or 'value' not in value_data:
# print(f"DEBUG: Fallback str(value_data): {str(value_data)}")
return str(value_data) # Fallback
val = value_data['value']
display_precision = value_data.get('display_precision')
# --- BEGIN DEBUG PRINT ---
# print(f"DEBUG: val: {val}, display_precision: {display_precision}, type(val): {type(val)}")
# --- END DEBUG PRINT ---
# Handle date objects first
if isinstance(val, datetime.date):
# print(f"DEBUG: Path: Date. Result: {date_utils.format_date_for_display(val)}")
return date_utils.format_date_for_display(val)
# Format number with precision
if display_precision is not None and isinstance(val, (float, decimal.Decimal)):
# Check if it's a rate to be displayed as percentage
if value_data.get("unit_display") and "%" in value_data["unit_display"]: # Check if unit_display exists and is not None
formatted_num = f"{val * 100:.{display_precision}f}"
else:
formatted_num = f"{val:.{display_precision}f}"
# Add thousands separator for currency-like values
if "currency" in value_data and val >= 1000:
parts = formatted_num.split('.')
parts[0] = "{:,}".format(int(parts[0].replace(',', ''))) # Apply to integer part
formatted_num = ".".join(parts)
elif isinstance(val, int):
formatted_num = str(val)
if "currency" in value_data and val >= 1000:
formatted_num = "{:,}".format(val)
else:
formatted_num = str(val)
if "currency" in value_data and value_data["currency"] is not None: # Ensure currency is not None
# print(f"DEBUG: Path: Currency. Result: {value_data['currency']} {formatted_num}")
return f"{value_data['currency']} {formatted_num}"
elif "unit_display" in value_data and value_data["unit_display"] is not None: # Ensure unit_display is not None
# print(f"DEBUG: Path: Unit Display. Result: {formatted_num}{value_data['unit_display']}")
return f"{formatted_num}{value_data['unit_display']}"
elif "unit" in value_data and value_data["unit"] is not None: # Ensure unit is not None
# print(f"DEBUG: Path: Unit. Result: {formatted_num} {value_data['unit']}")
return f"{formatted_num} {value_data['unit']}"
else:
# print(f"DEBUG: Path: Formatted Num Only. Result: {formatted_num}")
return formatted_num
if __name__ == '__main__':
print("Testing Value Sampler:")
# Test get_value_for_variable
print("\n--- Testing get_value_for_variable ---")
principal_data = get_value_for_variable("principal")
if principal_data:
print(f"Generated Principal Data: {principal_data}")
print(f"Formatted Principal: {format_value_for_display(principal_data)}")
rate_data = get_value_for_variable("simple_interest_rate_annual")
if rate_data:
print(f"Generated Rate Data: {rate_data}")
print(f"Formatted Rate: {format_value_for_display(rate_data)}")
time_data = get_value_for_variable("time_years")
if time_data:
print(f"Generated Time Data: {time_data}")
print(f"Formatted Time: {format_value_for_display(time_data)}")
# Test get_random_compounding_frequency
print("\n--- Testing get_random_compounding_frequency ---")
comp_freq = get_random_compounding_frequency()
if comp_freq:
print(f"Generated Compounding Frequency: {comp_freq}")
# Test edge cases or specific formatting
print("\n--- Testing Specific Formatting ---")
test_currency_large = {'key': 'principal', 'value': 1234567.89, 'currency': 'Php', 'display_precision': 2}
print(f"Large Currency: {format_value_for_display(test_currency_large)}")
test_currency_small = {'key': 'principal', 'value': 123.45, 'currency': 'Php', 'display_precision': 2}
print(f"Small Currency: {format_value_for_display(test_currency_small)}")
test_rate_percent = {'key': 'simple_interest_rate_annual', 'value': 0.085, 'unit_display': '% per annum', 'display_precision': 2}
print(f"Rate as Percent: {format_value_for_display(test_rate_percent)}")
test_integer_unit = {'key': 'time_years', 'value': 5, 'unit': 'years'}
print(f"Integer with Unit: {format_value_for_display(test_integer_unit)}")